refactor: refactored entire codebase
This commit is contained in:
@@ -2,4 +2,4 @@ DB_HOST='localhost'
|
||||
DB_PORT=5432
|
||||
DB_USERNAME='user'
|
||||
DB_PASSWORD='password'
|
||||
DB_NAME='test-db'
|
||||
DB_NAME='ddh'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# env file for e2e testing database
|
||||
# env file for e2e testing
|
||||
DB_HOST='localhost'
|
||||
DB_PORT=5432
|
||||
DB_USERNAME='user'
|
||||
DB_PASSWORD='password'
|
||||
DB_NAME='test-db'
|
||||
DB_NAME='ddh_tests' # running tests in a separate test db
|
||||
|
||||
14
.eslintrc.js
14
.eslintrc.js
@@ -7,31 +7,29 @@ module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin', '@typescript-eslint'],
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'airbnb-base',
|
||||
'plugin:@typescript-eslint/eslint-recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier',
|
||||
'prettier/@typescript-eslint',
|
||||
'plugin:import/typescript',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
// TS off
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
|
||||
// TS errors
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-misused-new': 'error',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'error',
|
||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
|
||||
// Eslint off
|
||||
'import/extensions': 'off',
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -4,11 +4,13 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/package-lock.json
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
@@ -30,8 +32,8 @@ lerna-debug.log*
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
# .vscode/*
|
||||
# !.vscode/settings.json
|
||||
# !.vscode/tasks.json
|
||||
# !.vscode/launch.json
|
||||
# !.vscode/extensions.json
|
||||
|
||||
11
.jestrc.json
11
.jestrc.json
@@ -3,17 +3,18 @@
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"coverageDirectory": "../tests/coverage",
|
||||
"setupFilesAfterEnv": ["./tests/jestSetupAfterEnv.ts"],
|
||||
"globalSetup": "<rootDir>/tests/jestGlobalSetup.ts",
|
||||
"setupFilesAfterEnv": ["./tests/setup/jestSetupAfterEnv.ts"],
|
||||
"globalSetup": "<rootDir>/tests/setup/jestGlobalSetup.ts",
|
||||
"testRegex": ".spec.ts$",
|
||||
"moduleNameMapper": {
|
||||
"@src/(.*)$": "<rootDir>/src/$1",
|
||||
"@modules/(.*)$": "<rootDir>/src/modules/$1",
|
||||
"@config/(.*)$": "<rootDir>/src/infrastructure/configs/$1",
|
||||
"@config/(.*)$": "<rootDir>/src/configs/$1",
|
||||
"@libs/(.*)$": "<rootDir>/src/libs/$1",
|
||||
"@exceptions$": "<rootDir>/src/libs/exceptions",
|
||||
"@libs/(.*)$": "<rootDir>/src/libs/$1"
|
||||
"@tests/(.*)$": "<rootDir>/tests/$1"
|
||||
},
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
}
|
||||
2
.vscode/ltex.dictionary.en-US.txt
vendored
Normal file
2
.vscode/ltex.dictionary.en-US.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
DTOs
|
||||
DTO
|
||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"cucumberautocomplete.steps": ["tests/**/*.ts"],
|
||||
"cucumberautocomplete.strictGherkinCompletion": true,
|
||||
"cSpell.words": ["Slonik"]
|
||||
}
|
||||
158
README.md
158
README.md
@@ -7,9 +7,25 @@
|
||||
|
||||
---
|
||||
|
||||
# Big update 10.10.2022
|
||||
|
||||
Codebase updated. Main changes:
|
||||
|
||||
- Simplified architecture, some boilerplate code removed.
|
||||
- Replaced [TypeOrm](https://typeorm.io/) for [Slonik](https://github.com/gajus/slonik). While ORMs are great for smaller projects, for projects with greater complexities raw queries are more flexible and performant.
|
||||
- Removed Unit of Work. Using global request transactions instead with the help of [nestjs-request-context](https://github.com/abonifacio/nestjs-request-context).
|
||||
- Updated all packages
|
||||
- Improved integration tests
|
||||
- General code improvements and refactoring
|
||||
- Bug fixes
|
||||
|
||||
You can find old version [here](https://github.com/Sairyss/domain-driven-hexagon/tree/7feca5cf992b47f3f28ccb1e9da5df0130f6d7ec) (or just browse code for old commits).
|
||||
|
||||
---
|
||||
|
||||
The main emphasis of this project is to provide recommendations on how to design software applications. This readme includes techniques, tools, best practices, architectural patterns and guidelines gathered from different sources.
|
||||
|
||||
Code examples are written using [NodeJS](https://nodejs.org/en/), [TypeScript](https://www.typescriptlang.org/), [NestJS](https://docs.nestjs.com/) framework and [Typeorm](https://www.npmjs.com/package/typeorm) for the database access.
|
||||
Code examples are written using [NodeJS](https://nodejs.org/en/), [TypeScript](https://www.typescriptlang.org/), [NestJS](https://docs.nestjs.com/) framework and [Slonik](https://github.com/gajus/slonik) for the database access.
|
||||
|
||||
Patterns and principles presented here are **framework/language agnostic**. Therefore, the above technologies can be easily replaced with any alternative. No matter what language or framework is used, any application can benefit from principles described below.
|
||||
|
||||
@@ -21,6 +37,7 @@ Patterns and principles presented here are **framework/language agnostic**. Ther
|
||||
---
|
||||
|
||||
- [Domain-Driven Hexagon](#domain-driven-hexagon)
|
||||
- [Big update 10.10.2022](#big-update-10102022)
|
||||
- [Architecture](#architecture)
|
||||
- [Pros](#pros)
|
||||
- [Cons](#cons)
|
||||
@@ -247,9 +264,8 @@ Avoid command handlers executing other commands in this fashion: Command → Com
|
||||
Example files:
|
||||
|
||||
- [create-user.command.ts](src/modules/user/commands/create-user/create-user.command.ts) - a command Object
|
||||
- [create-user.message.controller.ts](src/modules/user/commands/create-user/create-user.message.controller.ts) - controller executes a command using a bus. This decouples it from a command handler.
|
||||
- [create-user.service.ts](src/modules/user/commands/create-user/create-user.service.ts) - a command handler
|
||||
- [command-handler.base.ts](src/libs/ddd/domain/base-classes/command-handler.base.ts) - command handler base class that wraps execution in a transaction.
|
||||
- [create-user.message.controller.ts](src/modules/user/commands/create-user/create-user.message.controller.ts) - controller executes a command using a command bus. This decouples it from a command handler.
|
||||
- [create-user.service.ts](src/modules/user/commands/create-user/create-user.service.ts) - a command handler.
|
||||
|
||||
Read more:
|
||||
|
||||
@@ -260,16 +276,13 @@ Read more:
|
||||
|
||||
`Query` is similar to a `Command`. It signals user intent to find something and describes how to do it.
|
||||
|
||||
`Query` is used for retrieving data and should not make any state changes (like writes to the database, files etc.).
|
||||
|
||||
Queries are usually just a data retrieval operation and have no business logic involved; so, if needed, application and domain layers can be bypassed completely. Though, if some additional non-state changing logic has to be applied before returning a query response (like calculating something), it can be done in an application/domain layer.
|
||||
`Query` is just a data retrieval operation and should not make any state changes (like writes to the database, files, third party APIs, etc.).
|
||||
|
||||
Similarly to Commands, Queries can use a `Query Bus` if needed. This way you can query anything from anywhere without importing repositories directly and avoid coupling.
|
||||
|
||||
Example files:
|
||||
|
||||
- [find-users.query.ts](src/modules/user/queries/find-users/find-users.query.ts) - query object
|
||||
- [find-users.query-handler.ts](src/modules/user/queries/find-users/find-users.query-handler.ts) - example of a query bypassing application/domain layers completely
|
||||
- [find-users.query-handler.ts](src/modules/user/queries/find-users/find-users.query-handler.ts) - a query handler
|
||||
|
||||
---
|
||||
|
||||
@@ -309,10 +322,10 @@ In Application Core **dependencies point inwards**. Outer layers can depend on i
|
||||
|
||||
Example files:
|
||||
|
||||
- [repository.ports.ts](src/libs/ddd/domain/ports/repository.ports.ts) - generic port for repositories
|
||||
- [repository.port.ts](src/libs/ddd/repository.port.ts) - generic port for repositories
|
||||
- [user.repository.port.ts](src/modules/user/database/user.repository.port.ts) - a port for user repository
|
||||
- [find-users.query-handler.ts](src/modules/user/queries/find-users/find-users.query-handler.ts) - notice how query handler depends on a port instead of concrete repository implementation, and an implementation is injected
|
||||
- [logger.port.ts](src/libs/ddd/domain/ports/logger.port.ts) - another example of a port for application logger
|
||||
- [logger.port.ts](src/libs/ports/logger.port.ts) - another example of a port for application logger
|
||||
|
||||
Read more:
|
||||
|
||||
@@ -358,8 +371,8 @@ Entities:
|
||||
|
||||
Example files:
|
||||
|
||||
- [user.entity.ts](src/modules/user/domain/entities/user.entity.ts)
|
||||
- [wallet.entity.ts](src/modules/wallet/domain/entities/wallet.entity.ts)
|
||||
- [user.entity.ts](src/modules/user/domain/user.entity.ts)
|
||||
- [wallet.entity.ts](src/modules/wallet/domain/wallet.entity.ts)
|
||||
|
||||
Read more:
|
||||
|
||||
@@ -390,8 +403,8 @@ In summary, if you combine multiple related entities and value objects inside on
|
||||
|
||||
Example files:
|
||||
|
||||
- [aggregate-root.base.ts](src/libs/ddd/domain/base-classes/aggregate-root.base.ts) - abstract base class.
|
||||
- [user.entity.ts](src/modules/user/domain/entities/user.entity.ts) - aggregates are just entities that have to follow a set of specific rules described above.
|
||||
- [aggregate-root.base.ts](src/libs/ddd/aggregate-root.base.ts) - abstract base class.
|
||||
- [user.entity.ts](src/modules/user/domain/user.entity.ts) - aggregates are just entities that have to follow a set of specific rules described above.
|
||||
|
||||
Read more:
|
||||
|
||||
@@ -426,13 +439,10 @@ There are multiple ways on implementing an event bus for Domain Events, for exam
|
||||
|
||||
Examples:
|
||||
|
||||
- [domain-events.ts](src/libs/ddd/domain/domain-events/domain-events.ts) - this class is responsible for providing publish/subscribe functionality for anyone who needs to emit or listen to events. Keep in mind that this is just a proof of concept example and may not be a best solution for a production application.
|
||||
- [user-created.domain-event.ts](src/modules/user/domain/events/user-created.domain-event.ts) - simple object that holds data related to published event.
|
||||
- [create-wallet-when-user-is-created.domain-event-handler.ts](src/modules/wallet/application/event-handlers/create-wallet-when-user-is-created.domain-event-handler.ts) - this is an example of Domain Event Handler that executes some actions when a domain event is raised (in this case, when user is created it also creates a wallet for that user).
|
||||
- [typeorm.repository.base.ts](src/libs/ddd/infrastructure/database/base-classes/typeorm.repository.base.ts) - repository publishes all domain events for execution when it persists changes to an aggregate.
|
||||
- [typeorm-unit-of-work.ts](src/libs/ddd/infrastructure/database/base-classes/typeorm-unit-of-work.ts) - this ensures that all changes are saved in a single database transaction.
|
||||
- [unit-of-work.ts](src/infrastructure/database/unit-of-work/unit-of-work.ts) - here you create factories for specific Domain Repositories that are used in a transaction.
|
||||
- [create-user.service.ts](src/modules/user/commands/create-user/create-user.service.ts) - here we get a user repository from a `UnitOfWork` and execute a transaction.
|
||||
- [sql-repository.base.ts](src/libs/db/sql-repository.base.ts) - repository publishes all domain events for execution when it persists changes to an aggregate.
|
||||
- [create-user.service.ts](src/modules/user/commands/create-user/create-user.service.ts) - in a service we execute a global transaction to make sure all the changes done by Domain Events across the application are stored atomically (all or nothing).
|
||||
|
||||
To have a better understanding on domain events and implementation read this:
|
||||
|
||||
@@ -441,17 +451,13 @@ To have a better understanding on domain events and implementation read this:
|
||||
|
||||
**Additional notes**:
|
||||
|
||||
- This project uses custom implementation for publishing Domain Events. The reason for not using [Node Event Emitter](https://nodejs.org/api/events.html) or packages that offer an event bus (like [NestJS CQRS](https://docs.nestjs.com/recipes/cqrs)) is that they don't offer an option to `await` for all events to finish, which is useful when making all events a part of a transaction. Inside a single process either all changes done by events should be saved, or none of them in case if one of the events fails.
|
||||
|
||||
- Transactions are not required for some operations (for example queries or operations that don't cause any side effects in other aggregates) so you may skip using a unit of work in those cases.
|
||||
|
||||
- When using only events for complex workflows with a lot of steps, it will be hard to track everything that is happening across the application. One event may trigger another one, then another one, and so on. To track the entire workflow you'll have to go multiple places and search for an event handler for each step, which is hard to maintain. In this case, using a service/orchestrator/mediator might be a preferred approach compared to only using events since you will have an entire workflow in one place. This might create some coupling, but is easier to maintain. Don't rely on events only, pick the right tool for the job.
|
||||
|
||||
- In some cases you will not be able to save all changes done by your events to multiple aggregates in a single transaction. For example, if you are using microservices that span transaction between multiple services, or [Event Sourcing pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/event-sourcing) that has a single stream per aggregate. In this case saving events across multiple aggregates can be eventually consistent (for example by using [Sagas](https://microservices.io/patterns/data/saga.html) with compensating events or a [Process Manager](https://www.enterpriseintegrationpatterns.com/patterns/messaging/ProcessManager.html) or something similar).
|
||||
|
||||
## Integration Events
|
||||
|
||||
Out-of-process communications (calling microservices, external apis) are called `Integration Events`. If sending a Domain Event to external process is needed then domain event handler should send an `Integration Event`.
|
||||
Out-of-process communications (calling microservices, external APIs) are called `Integration Events`. If sending a Domain Event to external process is needed then domain event handler should send an `Integration Event`.
|
||||
|
||||
Integration Events usually should be published only after all Domain Events finished executing and saving all changes to the database.
|
||||
|
||||
@@ -529,7 +535,7 @@ Below we will discuss some validation techniques for your domain objects.
|
||||
|
||||
Example files:
|
||||
|
||||
- [wallet.entity.ts](src/modules/wallet/domain/entities/wallet.entity.ts) - notice `validate` method. This is a simplified example of enforcing a domain invariant.
|
||||
- [wallet.entity.ts](src/modules/wallet/domain/wallet.entity.ts) - notice `validate` method. This is a simplified example of enforcing a domain invariant.
|
||||
|
||||
Read more:
|
||||
|
||||
@@ -544,13 +550,25 @@ Significant business concepts can be expressed using specific types and classes.
|
||||
So, for example, `email` of type `string`:
|
||||
|
||||
```typescript
|
||||
email: string;
|
||||
const email: string = 'john@gmail.com';
|
||||
```
|
||||
|
||||
could be represented as a `Value Object` instead:
|
||||
|
||||
```typescript
|
||||
email: Email;
|
||||
export class Email extends ValueObject<string> {
|
||||
constructor(value: string) {
|
||||
super({ value });
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
const email: Email = new Email('john@gmail.com');
|
||||
```
|
||||
|
||||
Now the only way to make an `email` is to create a new instance of `Email` class first, this ensures it will be validated on creation and a wrong value won't get into `Entities`.
|
||||
@@ -581,10 +599,6 @@ Also, an alternative for creating an object may be a [type alias](https://www.ty
|
||||
|
||||
**Warning**: Don't include Value Objects in objects that can be sent to other processes, like dtos, events, database models etc. Serialize them to primitive types first.
|
||||
|
||||
Example files:
|
||||
|
||||
- [email.value-object.ts](src/modules/user/domain/value-objects/email.value-object.ts)
|
||||
|
||||
**Note**: In languages like TypeScript, creating value objects for single values/primitives adds some extra complexity and boilerplate code, since you need to access an underlying value by doing something like `email.value`. Also, it can have performance penalties due to creation of so many objects. This technique works best in languages like [Scala](https://www.scala-lang.org/) with its [value classes](https://docs.scala-lang.org/overviews/core/value-classes.html) that represents such classes as primitives at runtime, meaning that object `Email` will be represented as `String` at runtime.
|
||||
|
||||
**Note**: if you are using nodejs, [Runtypes](https://www.npmjs.com/package/runtypes) is a nice library that you can use instead of creating your own value objects for primitives.
|
||||
@@ -676,10 +690,10 @@ By combining compile and runtime validations, using objects instead of primitive
|
||||
|
||||
### Guarding vs validating
|
||||
|
||||
You may have noticed that we do validation in two places:
|
||||
You may have noticed that we do validation in multiple places:
|
||||
|
||||
1. First when user input is sent to our application. In our example we use DTO decorators: [create-user.request-dto.ts](src/modules/user/commands/create-user/create-user.request.dto.ts).
|
||||
2. Second time in domain objects, for example: [email.value-object.ts](src/modules/user/domain/value-objects/email.value-object.ts).
|
||||
2. Second time in domain objects, for example: [address.value-object.ts](src/modules/user/domain/value-objects/address.value-object.ts).
|
||||
|
||||
So, why are we validating things twice? Let's call a second validation "_guarding_", and distinguish between guarding and validating:
|
||||
|
||||
@@ -690,13 +704,13 @@ So, why are we validating things twice? Let's call a second validation "_guardin
|
||||
|
||||
The input coming from the outside world should be filtered out before passing it further to the domain model. It’s the first line of defense against data inconsistency. At this stage, any incorrect data is denied with corresponding error messages.
|
||||
Once the filtration has confirmed that the incoming data is valid it's passed to a domain. When the data enters the always-valid domain boundary, it's assumed to be valid and any violation of this assumption means that you’ve introduced a bug.
|
||||
Guards help to reveal those bugs. They are the failsafe mechanism, the last line of defense that ensures data in the always-valid boundary is indeed valid. Unlike validations, guards throw exceptions; they comply with the [Fail Fast principle](https://enterprisecraftsmanship.com/posts/fail-fast-principle).
|
||||
Guards help to reveal those bugs. They are the failsafe mechanism, the last line of defense that ensures data in the always-valid boundary is indeed valid. Guards comply with the [Fail Fast principle](https://enterprisecraftsmanship.com/posts/fail-fast-principle) by throwing runtime exceptions.
|
||||
|
||||
Domain classes should always guard themselves against becoming invalid.
|
||||
|
||||
For preventing null/undefined values, empty objects and arrays, incorrect input length etc. a library of [guards](<https://en.wikipedia.org/wiki/Guard_(computer_science)>) can be created.
|
||||
|
||||
Example file: [guard.ts](src/libs/ddd/domain/guard.ts)
|
||||
Example file: [guard.ts](src/libs/guard.ts)
|
||||
|
||||
**Keep in mind** that not all validations/guarding can be done in a single domain object, it should validate only rules shared by all contexts. There are cases when validation may be different depending on a context, or one field may involve another field, or even a different entity. Handle those cases accordingly.
|
||||
|
||||
@@ -777,7 +791,8 @@ function createUser(
|
||||
return Err(new IncorrectUserAddressError());
|
||||
}
|
||||
// else
|
||||
const user = await this.userRepo.create(user);
|
||||
const user = UserEntity.create(command);
|
||||
await this.userRepo.save(user);
|
||||
return Ok(user);
|
||||
}
|
||||
```
|
||||
@@ -787,15 +802,15 @@ This approach gives us a fixed set of expected error types, so we can decide wha
|
||||
```typescript
|
||||
/* in HTTP context we want to convert each error to an
|
||||
error with a corresponding HTTP status code: 409, 400 or 500 */
|
||||
const result = createUser(command);
|
||||
if (result.isOk()) return user.id;
|
||||
if (result.isErr()) {
|
||||
if (result.err instanceof UserAlreadyExistsError)
|
||||
throw new ConflictException(error.message);
|
||||
if (result.err instanceof IncorrectUserAddressError)
|
||||
throw new BadRequestException(error.message);
|
||||
else throw new InternalServerError();
|
||||
}
|
||||
const result = await this.commandBus.execute(command);
|
||||
return match(result, {
|
||||
Ok: (id: string) => new IdResponse(id),
|
||||
Err: (error: Error) => {
|
||||
if (error instanceof UserAlreadyExistsError)
|
||||
throw new ConflictHttpException(error.message);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Throwing makes errors invisible for the consumer of your functions/methods (until those errors happen at runtime, or until you dig deeply into the source code and find them). This means those errors are less likely to be handled properly.
|
||||
@@ -811,12 +826,11 @@ Libraries you can use:
|
||||
|
||||
Example files:
|
||||
|
||||
- [user.errors.ts](src/modules/user/errors/user.errors.ts) - user errors
|
||||
- [user.errors.ts](src/modules/user/domain/user.errors.ts) - user errors
|
||||
- [create-user.service.ts](src/modules/user/commands/create-user/create-user.service.ts) - notice how `Err(new UserAlreadyExistsError())` is returned instead of throwing it.
|
||||
- [create-user.http.controller.ts](src/modules/user/commands/create-user/create-user.http.controller.ts) - in a user http controller we match an error and decide what to do with it. If an error is `UserAlreadyExistsError` we throw a `Conflict Exception` which a user will receive as `409 - Conflict`. If an error is unknown we just throw it and NestJS will return it to the user as `500 - Internal Server Error`.
|
||||
- [create-user.http.controller.ts](src/modules/user/commands/create-user/create-user.http.controller.ts) - in a user http controller we match an error and decide what to do with it. If an error is `UserAlreadyExistsError` we throw a `Conflict Exception` which a user will receive as `409 - Conflict`. If an error is unknown we just throw it and our framework will return it to the user as `500 - Internal Server Error`.
|
||||
- [create-user.cli.controller.ts](src/modules/user/commands/create-user/create-user.cli.controller.ts) - in a CLI controller we don't care about returning a correct status code so we just `.unwrap()` a result, which will just throw in case of an error.
|
||||
- [exceptions](src/libs/exceptions) folder contains some generic app exceptions (not domain specific)
|
||||
- [exception.interceptor.ts](src/infrastructure/interceptors/exception.interceptor.ts) - in this file we convert our app's generic exceptions into a NestJS HTTP exceptions. This way we are not tied to a framework or HTTP protocol.
|
||||
|
||||
Read more:
|
||||
|
||||
@@ -827,7 +841,7 @@ Read more:
|
||||
|
||||
## Using libraries inside Application's core
|
||||
|
||||
Whether or not to use libraries in application core and especially domain layer is a subject of a lot of debates. In real world, injecting every library instead of importing it directly is not always practical, so exceptions can be made for some single responsibility libraries that help to implement domain logic (like working with numbers).
|
||||
Whether to use libraries in application core and especially domain layer is a subject of a lot of debates. In real world, injecting every library instead of importing it directly is not always practical, so exceptions can be made for some single responsibility libraries that help to implement domain logic (like working with numbers).
|
||||
|
||||
Main recommendations to keep in mind is that libraries imported in application's core **shouldn't** expose:
|
||||
|
||||
@@ -880,7 +894,7 @@ One of the main benefits of a layered architecture is separation of concerns. As
|
||||
|
||||
Example files:
|
||||
|
||||
- [create-user.graphql-resolver.ts](src/modules/user/commands/create-user/create-user.graphql-resolver.ts)
|
||||
- [create-user.graphql-resolver.ts](src/modules/user/commands/create-user/graphql-example/create-user.graphql-resolver.ts)
|
||||
|
||||
---
|
||||
|
||||
@@ -898,7 +912,6 @@ Input data sent by a user.
|
||||
Examples:
|
||||
|
||||
- [create-user.request.dto.ts](src/modules/user/commands/create-user/create-user.request.dto.ts)
|
||||
- [create.user.interface.ts](src/interface-adapters/interfaces/user/create.user.interface.ts)
|
||||
|
||||
### Response DTOs
|
||||
|
||||
@@ -909,11 +922,10 @@ Output data returned to a user.
|
||||
Examples:
|
||||
|
||||
- [user.response.dto.ts](src/modules/user/dtos/user.response.dto.ts)
|
||||
- [user.interface.ts](src/interface-adapters/interfaces/user/user.interface.ts)
|
||||
|
||||
---
|
||||
|
||||
Using DTOs protects your clients from internal data structure changes that may happen in your API. When internal data models change (like renaming variables or splitting tables), they can still be mapped to match a corresponding DTO to maintain compatibility for anyone using your API.
|
||||
DTO contracts protect your clients from internal data structure changes that may happen in your API. When internal data models change (like renaming variables or splitting tables), they can still be mapped to match a corresponding DTO to maintain compatibility for anyone using your API.
|
||||
|
||||
When updating DTO interfaces, a new version of API can be created by prefixing an endpoint with a version number, for example: `v2/users`. This will make transition painless by preventing breaking compatibility for users that are slow to update their apps that uses your API.
|
||||
|
||||
@@ -928,21 +940,21 @@ More info on this subject here: [Are CQRS commands part of the domain model?](ht
|
||||
|
||||
- DTOs should be data-oriented, not object-oriented. Its properties should be mostly primitives. We are not modeling anything here, just sending flat data around.
|
||||
- When returning a `Response` prefer _whitelisting_ properties over _blacklisting_. This ensures that no sensitive data will leak in case if programmer forgets to blacklist newly added properties that shouldn't be returned to the user.
|
||||
- Interfaces for `Request`/`Response` objects should be kept somewhere in shared directory instead of module directory since they may be used by a different application (like front-end page, mobile app or microservice). Consider creating a git submodule or a separate package for sharing interfaces.
|
||||
- If you use the same DTOs in multiple apps (frontend and backend, or between microservices), you can keep them somewhere in a shared directory instead of module directory and create a git submodule or a separate package for sharing them.
|
||||
- `Request`/`Response` DTO classes may be a good place to use validation and sanitization decorators like [class-validator](https://www.npmjs.com/package/class-validator) and [class-sanitizer](https://www.npmjs.com/package/class-sanitizer) (make sure that all validation errors are gathered first and only then return them to the user, this is called [Notification pattern](https://martinfowler.com/eaaDev/Notification.html). Class-validator does this by default).
|
||||
- `Request`/`Response` DTO classes may also be a good place to use Swagger/OpenAPI library decorators that [NestJS provides](https://docs.nestjs.com/openapi/types-and-parameters).
|
||||
- If DTO decorators for validation/documentation are not used, DTO can be just an interface instead of class + interface.
|
||||
- Data can be transformed to DTO format using a separate mapper or right in the constructor if DTO classes are used.
|
||||
- If DTO decorators for validation/documentation are not used, DTO can be just an interface instead of a class.
|
||||
- Data can be transformed to DTO format using a separate mapper or right in the constructor of a DTO class.
|
||||
|
||||
### Local DTOs
|
||||
|
||||
Another thing that can be seen in some projects is local DTOs. Some people prefer to never use domain objects (like entities) outside of its domain (in `controllers`, for example) and return a plain DTO object instead. This project doesn't use this technique, to avoid extra complexity and boilerplate code like interfaces and data mapping.
|
||||
Another thing that can be seen in some projects is local DTOs. Some people prefer to never use domain objects (like entities) outside its domain (in `controllers`, for example) and return a plain DTO object instead. This project doesn't use this technique, to avoid extra complexity and boilerplate code like interfaces and data mapping.
|
||||
|
||||
[Here](https://martinfowler.com/bliki/LocalDTO.html) are Martin Fowler's thoughts on local DTOs, in short (quote):
|
||||
|
||||
> Some people argue for them (DTOs) as part of a Service Layer API because they ensure that service layer clients aren't dependent upon an underlying Domain Model. While that may be handy, I don't think it's worth the cost of all of that data mapping.
|
||||
|
||||
Though you may want to introduce Local DTOs when you need to decouple modules properly. For example, when querying from one module to another you don't want to leak your entities between modules. In that case using a Local DTO may be a better idea.
|
||||
Though you may want to introduce Local DTOs when you need to decouple modules properly. For example, when querying from one module to another you don't want to leak your entities between modules. In that case using a Local DTO may be justified.
|
||||
|
||||
---
|
||||
|
||||
@@ -985,11 +997,11 @@ The data flow here looks something like this: repository receives a domain `Enti
|
||||
|
||||
Application's core usually is not allowed to depend on repositories directly, instead it depends on abstractions (ports/interfaces). This makes data retrieval technology-agnostic.
|
||||
|
||||
**Note**: in theory, most publications out there recommend abstracting a database with interfaces. In practice, it's not always useful. Most of the projects out there never change database technology (or rewrite most of the code anyway if they do). Another downside is that if you abstract a database you are more likely not using its full potential. This project abstracts repositories with a generic port to make a practical example [repository.ports.ts](src/libs/ddd/domain/ports/repository.ports.ts), but this doesn't mean you should do that too. Think carefully before using abstractions. More info on this topic: [Should you Abstract the Database?](https://enterprisecraftsmanship.com/posts/should-you-abstract-database/)
|
||||
**Note**: in theory, most publications out there recommend abstracting a database with interfaces. In practice, it's not always useful. Most of the projects out there never change database technology (or rewrite most of the code anyway if they do). Another downside is that if you abstract a database you are more likely not using its full potential. This project abstracts repositories with a generic port to make a practical example [repository.port.ts](src/libs/ddd/repository.port.ts), but this doesn't mean you should do that too. Think carefully before using abstractions. More info on this topic: [Should you Abstract the Database?](https://enterprisecraftsmanship.com/posts/should-you-abstract-database/)
|
||||
|
||||
Example files:
|
||||
|
||||
This project contains abstract repository class that allows to make basic CRUD operations: [typeorm.repository.base.ts](src/libs/ddd/infrastructure/database/base-classes/typeorm.repository.base.ts). This base class is then extended by a specific repository, and all specific operations that an entity may need is implemented in that specific repo: [user.repository.ts](src/modules/user/database/user.repository.ts).
|
||||
This project contains abstract repository class that allows to make basic CRUD operations: [sql-repository.base.ts](src/libs/db/sql-repository.base.ts). This base class is then extended by a specific repository, and all specific operations that an entity may need are implemented in that specific repo: [user.repository.ts](src/modules/user/database/user.repository.ts).
|
||||
|
||||
Read more:
|
||||
|
||||
@@ -997,25 +1009,25 @@ Read more:
|
||||
|
||||
## Persistence models
|
||||
|
||||
Using a single entity for domain logic and database concerns leads to a database-centric architecture. In DDD world domain model and persistance model should be separated.
|
||||
Using a single entity for domain logic and database concerns leads to a database-centric architecture. In DDD world domain model and persistence model should be separated.
|
||||
|
||||
Since domain `Entities` have their data modeled so that it best accommodates domain logic, it may be not in the best shape to save in a database. For that purpose `Persistence models` can be created that have a shape that is better represented in a particular database that is used. Domain layer should not know anything about persistance models, and it should not care.
|
||||
Since domain `Entities` have their data modeled so that it best accommodates domain logic, it may be not in the best shape to save in a database. For that purpose `Persistence models` can be created that have a shape that is better represented in a particular database that is used. Domain layer should not know anything about persistence models, and it should not care.
|
||||
|
||||
There can be multiple models optimized for different purposes, for example:
|
||||
|
||||
- Domain with its own models - `Entities`, `Aggregates` and `Value Objects`.
|
||||
- Persistence layer with its own models - ORM ([Object–relational mapping](https://en.wikipedia.org/wiki/Object%E2%80%93relational_mapping)), schemas, read/write models if databases are separated into a read and write db ([CQRS](https://en.wikipedia.org/wiki/Command%E2%80%93query_separation)) etc.
|
||||
|
||||
Over time, when the amount of data grows, there may be a need to make some changes in the database like improving performance or data integrity by re-designing some tables or even changing the database entirely. Without an explicit separation between `Domain` and `Persistance` models any change to the database will lead to change in your domain `Entities` or `Aggregates`. For example, when performing a database [normalization](https://en.wikipedia.org/wiki/Database_normalization) data can spread across multiple tables rather than being in one table, or vice-versa for [denormalization](https://en.wikipedia.org/wiki/Denormalization). This may force a team to do a complete refactoring of a domain layer which may cause unexpected bugs and challenges. Separating Domain and Persistance models prevents that.
|
||||
Over time, when the amount of data grows, there may be a need to make some changes in the database like improving performance or data integrity by re-designing some tables or even changing the database entirely. Without an explicit separation between `Domain` and `Persistance` models any change to the database will lead to change in your domain `Entities` or `Aggregates`. For example, when performing a database [normalization](https://en.wikipedia.org/wiki/Database_normalization) data can spread across multiple tables rather than being in one table, or vice-versa for [denormalization](https://en.wikipedia.org/wiki/Denormalization). This may force a team to do a complete refactoring of a domain layer which may cause unexpected bugs and challenges. Separating Domain and Persistence models prevents that.
|
||||
|
||||
**Note**: separating domain and persistance models may be overkill for smaller applications. It requires a lot of effort creating and maintaining boilerplate code like mappers and abstractions. Consider all pros and cons before making this decision.
|
||||
**Note**: separating domain and persistence models may be overkill for smaller applications. It requires a lot of effort creating and maintaining boilerplate code like mappers and abstractions. Consider all pros and cons before making this decision.
|
||||
|
||||
Example files:
|
||||
|
||||
- [user.orm-entity.ts](src/modules/user/database/user.orm-entity.ts) <- Persistence model using ORM.
|
||||
- [user.orm-mapper.ts](src/modules/user/database/user.orm-mapper.ts) <- Persistence models should also have a corresponding mapper to map from domain to persistence and back.
|
||||
- [user.repository.ts](src/modules/user/database/user.repository.ts) <- notice `userSchema` and `UserModel` type that describe how user looks in a database
|
||||
- [user.mapper.ts](src/modules/user/user.mapper.ts) <- Persistence models should also have a corresponding mapper to map from domain to persistence and back.
|
||||
|
||||
Alternative approach to ORM are raw queries or some sort of query builder (like [knex](https://www.npmjs.com/package/knex)). This may be a better approach for bigger projects than Object-Relational Mapping since it offers more flexibility and better performance.
|
||||
For smaller projects you could use [ORM](https://en.wikipedia.org/wiki/Object%E2%80%93relational_mapping) libraries like [Typeorm](https://typeorm.io/) for simplicity. But for projects with more complexity ORMs are not flexible and performant enough. For this reason, this project uses raw queries with a [Slonik](https://github.com/gajus/slonik) client library.
|
||||
|
||||
Read more:
|
||||
|
||||
@@ -1086,8 +1098,8 @@ For BDD tests [Cucumber](https://cucumber.io/) with [Gherkin](https://cucumber.i
|
||||
|
||||
Example files:
|
||||
|
||||
- [create-user.feature](https://github.com/Sairyss/domain-driven-hexagon/blob/master/tests/user/create-user/create-user.feature) - feature file that contains human-readable Gherkin steps
|
||||
- [create-user.e2e-spec.ts](https://github.com/Sairyss/domain-driven-hexagon/blob/master/tests/user/create-user/create-user.e2e-spec.ts) - e2e / behavioral test
|
||||
- [create-user.feature](tests/user/create-user/create-user.feature) - feature file that contains human-readable Gherkin steps
|
||||
- [create-user.e2e-spec.ts](tests/user/create-user/create-user.e2e-spec.ts) - e2e / behavioral test
|
||||
|
||||
Read more:
|
||||
|
||||
@@ -1103,7 +1115,7 @@ It would be more logical to separate every module by components and have all rel
|
||||
|
||||
And shared files, like domain objects (entities/aggregates), repositories, shared dtos and interfaces etc are stored apart since those are reused by multiple use-cases. Domain layer is isolated, and use-cases which are essentially wrappers around business logic are treated as components. This approach makes navigation and maintaining easier. Check [user](src/modules/user) module for more examples.
|
||||
|
||||
This is called [The Common Closure Principle (CCP)](https://ericbackhage.net/clean-code/the-common-closure-principle/). Folder/file structure in this project uses this principle. Related files that usually change together (and are not used by anything else outside of that component) are stored close together, in a single use-case folder.
|
||||
This is called [The Common Closure Principle (CCP)](https://ericbackhage.net/clean-code/the-common-closure-principle/). Folder/file structure in this project uses this principle. Related files that usually change together (and are not used by anything else outside that component) are stored close together, in a single use-case folder.
|
||||
|
||||
> The aim here should to be strategic and place classes that we, from experience, know often changes together into the same component.
|
||||
|
||||
@@ -1131,7 +1143,9 @@ Read more:
|
||||
|
||||
### File names
|
||||
|
||||
Consider giving a descriptive type names to files after a dot "`.`", like `*.service.ts` or `*.entity.ts`. This makes it easier to differentiate what files does what and makes it easier to find those files using [fuzzy search](https://en.wikipedia.org/wiki/Approximate_string_matching) (`CTRL+P` for Windows/Linux and `⌘+P` for MacOS in VSCode to try it out).
|
||||
Consider giving a descriptive type names to files after a dot "`.`", like `*.service.ts` or `*.entity.ts`. This makes it easier to differentiate what files do what and makes it easier to find those files using [fuzzy search](https://en.wikipedia.org/wiki/Approximate_string_matching) (`CTRL+P` for Windows/Linux and `⌘+P` for MacOS in VSCode to try it out).
|
||||
|
||||
Alternatively you could use class names as file names, but consider adding descriptive suffixes like `Service` or `Controller`, etc.
|
||||
|
||||
Read more:
|
||||
|
||||
|
||||
27
database/getMigrator.ts
Normal file
27
database/getMigrator.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
|
||||
import { SlonikMigrator } from '@slonik/migrator';
|
||||
import { createPool } from 'slonik';
|
||||
import * as dotenv from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
// use .env or .env.test depending on NODE_ENV variable
|
||||
const envPath = path.resolve(
|
||||
__dirname,
|
||||
process.env.NODE_ENV === 'test' ? '../.env.test' : '../.env',
|
||||
);
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
export async function getMigrator() {
|
||||
const pool = await createPool(
|
||||
`postgres://${process.env.DB_USERNAME}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}/${process.env.DB_NAME}`,
|
||||
);
|
||||
|
||||
const migrator = new SlonikMigrator({
|
||||
migrationsPath: path.resolve(__dirname, 'migrations'),
|
||||
migrationTableName: 'migration',
|
||||
slonik: pool,
|
||||
} as any);
|
||||
|
||||
return { pool, migrator };
|
||||
}
|
||||
10
database/migrate.ts
Normal file
10
database/migrate.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { getMigrator } from './getMigrator';
|
||||
|
||||
export async function run() {
|
||||
const { migrator } = await getMigrator();
|
||||
migrator.runAsCLI();
|
||||
console.log('Done');
|
||||
}
|
||||
|
||||
run();
|
||||
12
database/migrations/2022.10.07T13.49.19.users.sql
Normal file
12
database/migrations/2022.10.07T13.49.19.users.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "users" (
|
||||
"id" character varying NOT NULL,
|
||||
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"email" character varying NOT NULL,
|
||||
"country" character varying NOT NULL,
|
||||
"postalCode" character varying NOT NULL,
|
||||
"street" character varying NOT NULL,
|
||||
"role" character varying NOT NULL,
|
||||
CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"),
|
||||
CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id")
|
||||
)
|
||||
9
database/migrations/2022.10.07T13.49.54.wallets.sql
Normal file
9
database/migrations/2022.10.07T13.49.54.wallets.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE "wallets" (
|
||||
"id" character varying NOT NULL,
|
||||
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"balance" integer NOT NULL DEFAULT '0',
|
||||
"userId" character varying NOT NULL,
|
||||
CONSTRAINT "UQ_35472b1fe48b6330cd349709564" UNIQUE ("userId"),
|
||||
CONSTRAINT "PK_bec464dd8d54c39c54fd32e2334" PRIMARY KEY ("id")
|
||||
)
|
||||
1
database/migrations/down/2022.10.07T13.49.19.users.sql
Normal file
1
database/migrations/down/2022.10.07T13.49.19.users.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE "users"
|
||||
1
database/migrations/down/2022.10.07T13.49.54.wallets.sql
Normal file
1
database/migrations/down/2022.10.07T13.49.54.wallets.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE "wallets"
|
||||
33
database/seed.ts
Normal file
33
database/seed.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { getMigrator } from './getMigrator';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
// Utility function to run a migration
|
||||
export const seed = async (query, file) => {
|
||||
console.log(`executing migration: ${file} ...`);
|
||||
const { pool, migrator } = await getMigrator();
|
||||
await migrator.up();
|
||||
await pool.query(query);
|
||||
console.log(`${file} migration executed`);
|
||||
};
|
||||
|
||||
const directoryPath = path.join(__dirname, 'seeds');
|
||||
async function runAll() {
|
||||
fs.readdir(directoryPath, async function (err, files) {
|
||||
if (err) {
|
||||
return console.log('Unable to scan directory: ' + err);
|
||||
}
|
||||
for (const file of files) {
|
||||
const data = fs.readFileSync(path.resolve(directoryPath, file), {
|
||||
encoding: 'utf8',
|
||||
flag: 'r',
|
||||
});
|
||||
await seed({ sql: data, values: [], type: 'SLONIK_TOKEN_SQL' }, file);
|
||||
}
|
||||
console.log('done');
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
runAll();
|
||||
22
database/seeds/users.seed.sql
Normal file
22
database/seeds/users.seed.sql
Normal file
@@ -0,0 +1,22 @@
|
||||
INSERT INTO
|
||||
users (
|
||||
id,
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
email,
|
||||
country,
|
||||
"postalCode",
|
||||
street,
|
||||
"role"
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'f59d0748-d455-4465-b0a8-8d8260b1c877',
|
||||
now(),
|
||||
now(),
|
||||
'john@gmail.com',
|
||||
'England',
|
||||
'24312',
|
||||
'Road Avenue',
|
||||
'guest'
|
||||
);
|
||||
10
database/seeds/wallets.seed.sql
Normal file
10
database/seeds/wallets.seed.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
INSERT INTO
|
||||
wallets (id, "createdAt", "updatedAt", balance, "userId")
|
||||
VALUES
|
||||
(
|
||||
gen_random_uuid(),
|
||||
now(),
|
||||
now(),
|
||||
0,
|
||||
'f59d0748-d455-4465-b0a8-8d8260b1c877'
|
||||
);
|
||||
@@ -9,8 +9,9 @@ services:
|
||||
environment:
|
||||
POSTGRES_USER: 'user'
|
||||
POSTGRES_PASSWORD: 'password'
|
||||
POSTGRES_DB: 'ddh'
|
||||
volumes:
|
||||
- ../data/db:/var/lib/postgresql/data
|
||||
- ddh-postgres:/var/lib/postgresql/data
|
||||
networks:
|
||||
- postgres
|
||||
|
||||
@@ -28,3 +29,6 @@ services:
|
||||
networks:
|
||||
postgres:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
ddh-postgres:
|
||||
@@ -3,15 +3,16 @@
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"coverageDirectory": "./coverage",
|
||||
"setupFilesAfterEnv": ["./tests/jestSetupAfterEnv.ts"],
|
||||
"globalSetup": "<rootDir>/tests/jestGlobalSetup.ts",
|
||||
"setupFilesAfterEnv": ["./tests/setup/jestSetupAfterEnv.ts"],
|
||||
"globalSetup": "<rootDir>/tests/setup/jestGlobalSetup.ts",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"moduleNameMapper": {
|
||||
"@src/(.*)$": "<rootDir>/src/$1",
|
||||
"@modules/(.*)$": "<rootDir>/src/modules/$1",
|
||||
"@config/(.*)$": "<rootDir>/src/infrastructure/configs/$1",
|
||||
"@config/(.*)$": "<rootDir>/src/configs/$1",
|
||||
"@libs/(.*)$": "<rootDir>/src/libs/$1",
|
||||
"@exceptions$": "<rootDir>/src/libs/exceptions",
|
||||
"@libs/(.*)$": "<rootDir>/src/libs/$1"
|
||||
"@tests/(.*)$": "<rootDir>/tests/$1"
|
||||
},
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
|
||||
40439
package-lock.json
generated
40439
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
148
package.json
148
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "domain-driven-hexagon",
|
||||
"version": "0.0.1",
|
||||
"version": "2.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -18,82 +18,90 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest -i --forceExit --config jest-e2e.json",
|
||||
"test:all": "npm run test && npm run test:e2e",
|
||||
"typeorm": "ts-node -r tsconfig-paths/register --project ./tsconfig.json ./node_modules/.bin/typeorm",
|
||||
"test:e2e": "jest -i --config jest-e2e.json",
|
||||
"docker:env": "docker-compose --file docker/docker-compose.yml up --build",
|
||||
"migration:generate": "npm run typeorm -- migration:generate --config src/infrastructure/configs/database.config",
|
||||
"migration:run": "npm run typeorm -- migration:run --config src/infrastructure/configs/database.config",
|
||||
"migration:revert": "npm run typeorm -- migration:revert --config src/infrastructure/configs/database.config",
|
||||
"seed:run": "ts-node -r tsconfig-paths/register --project ./tsconfig.json ./node_modules/typeorm-seeding/dist/cli.js seed --root src/infrastructure/configs"
|
||||
"migration:create": "ts-node database/migrate create --name",
|
||||
"migration:up": "ts-node database/migrate up",
|
||||
"migration:up:tests": "NODE_ENV=test ts-node database/migrate up",
|
||||
"migration:down": "ts-node database/migrate down",
|
||||
"migration:down:tests": "NODE_ENV=test ts-node database/migrate down",
|
||||
"migration:executed": "ts-node database/migrate executed",
|
||||
"migration:executed:tests": "NODE_ENV=test ts-node database/migrate executed",
|
||||
"migration:pending": "ts-node database/migrate pending",
|
||||
"migration:pending:tests": "NODE_ENV=test ts-node database/migrate pending",
|
||||
"seed:up": "ts-node database/seed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/gateway": "^0.41.0",
|
||||
"@badrap/result": "^0.2.8",
|
||||
"@nestjs/common": "^7.0.0",
|
||||
"@nestjs/core": "^7.0.0",
|
||||
"@nestjs/cqrs": "^8.0.0",
|
||||
"@nestjs/graphql": "^9.0.4",
|
||||
"@nestjs/microservices": "^7.6.12",
|
||||
"@nestjs/platform-express": "^7.0.0",
|
||||
"@nestjs/swagger": "^4.7.5",
|
||||
"@nestjs/typeorm": "^7.1.5",
|
||||
"apollo-server-express": "^3.3.0",
|
||||
"class-transformer": "^0.3.1",
|
||||
"class-validator": "^0.12.2",
|
||||
"dotenv": "^8.2.0",
|
||||
"env-var": "^7.1.1",
|
||||
"graphql": "^15.6.0",
|
||||
"i": "^0.3.7",
|
||||
"nanoid": "^3.1.25",
|
||||
"nest-event": "^1.0.8",
|
||||
"nestjs-console": "^7.0.0",
|
||||
"npm": "^7.24.1",
|
||||
"oxide.ts": "^0.9.12",
|
||||
"pg": "^8.5.1",
|
||||
"@nestjs/apollo": "^10.1.3",
|
||||
"@nestjs/common": "^9.0.0",
|
||||
"@nestjs/core": "^9.0.0",
|
||||
"@nestjs/cqrs": "^9.0.1",
|
||||
"@nestjs/event-emitter": "^1.3.1",
|
||||
"@nestjs/graphql": "^10.1.2",
|
||||
"@nestjs/microservices": "^9.1.2",
|
||||
"@nestjs/platform-express": "^9.0.0",
|
||||
"@nestjs/swagger": "^6.1.2",
|
||||
"@slonik/migrator": "^0.11.3",
|
||||
"apollo-server-core": "^3.10.2",
|
||||
"apollo-server-express": "^3.10.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.13.2",
|
||||
"dotenv": "^16.0.2",
|
||||
"env-var": "^7.3.0",
|
||||
"jest-cucumber": "^3.0.1",
|
||||
"nanoid": "^3.3.4",
|
||||
"nestjs-console": "^8.0.0",
|
||||
"nestjs-request-context": "^2.1.0",
|
||||
"nestjs-slonik": "^9.0.0",
|
||||
"oxide.ts": "^1.0.5",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rimraf": "^3.0.2",
|
||||
"rxjs": "^6.5.4",
|
||||
"swagger-ui-express": "^4.1.5",
|
||||
"ts-morph": "^12.0.0",
|
||||
"typeorm": "^0.2.29",
|
||||
"typeorm-seeding": "^1.6.1",
|
||||
"uuid": "^8.3.1",
|
||||
"validator": "^13.1.17",
|
||||
"ws": "^8.2.2"
|
||||
"rxjs": "^7.2.0",
|
||||
"slonik": "^31.2.4",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^7.0.0",
|
||||
"@nestjs/schematics": "^7.0.0",
|
||||
"@nestjs/testing": "^7.0.0",
|
||||
"@types/express": "^4.17.3",
|
||||
"@types/faker": "^5.1.6",
|
||||
"@types/jest": "26.0.10",
|
||||
"@types/joi": "^14.3.4",
|
||||
"@types/node": "^13.9.1",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@types/uuid": "^8.3.0",
|
||||
"@types/ws": "^7.4.7",
|
||||
"@typescript-eslint/eslint-plugin": "3.9.1",
|
||||
"@typescript-eslint/parser": "3.9.1",
|
||||
"eslint": "7.7.0",
|
||||
"eslint-config-airbnb": "^18.2.0",
|
||||
"eslint-config-prettier": "^6.10.0",
|
||||
"eslint-plugin-import": "^2.20.1",
|
||||
"faker": "^5.3.1",
|
||||
"jest": "26.4.2",
|
||||
"jest-cucumber": "^3.0.1",
|
||||
"prettier": "^1.19.1",
|
||||
"supertest": "^4.0.2",
|
||||
"ts-jest": "26.2.0",
|
||||
"ts-loader": "^6.2.1",
|
||||
"ts-node": "9.0.0",
|
||||
"tsconfig-paths": "^3.9.0",
|
||||
"typescript": "^4.1.5"
|
||||
"@nestjs/cli": "^9.0.0",
|
||||
"@nestjs/schematics": "^9.0.0",
|
||||
"@nestjs/testing": "^9.0.0",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "28.1.8",
|
||||
"@types/node": "^16.0.0",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.0.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint": "^8.0.1",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "28.1.3",
|
||||
"prettier": "^2.3.2",
|
||||
"source-map-support": "^0.5.20",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "28.0.8",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsconfig-paths": "4.1.0",
|
||||
"typescript": "^4.7.4"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-push": "npm run test:all && npm run lint && npm run format"
|
||||
}
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
"volta": {
|
||||
"node": "16.16.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { SlonikModule } from 'nestjs-slonik';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { UserModule } from '@modules/user/user.module';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { NestEventModule } from 'nest-event';
|
||||
import { ConsoleModule } from 'nestjs-console';
|
||||
import { WalletModule } from '@modules/wallet/wallet.module';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
import { join } from 'path';
|
||||
import { typeormConfig } from './infrastructure/configs/ormconfig';
|
||||
import { UnitOfWorkModule } from './infrastructure/database/unit-of-work/unit-of-work.module';
|
||||
import { RequestContextModule } from 'nestjs-request-context';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { ContextInterceptor } from './libs/application/context/ContextInterceptor';
|
||||
import { ExceptionInterceptor } from '@libs/application/interceptors/exception.interceptor';
|
||||
import { postgresConnectionUri } from './configs/database.config';
|
||||
|
||||
const interceptors = [
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ContextInterceptor,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ExceptionInterceptor,
|
||||
},
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRoot(typeormConfig),
|
||||
// only if you are using GraphQL
|
||||
GraphQLModule.forRoot({
|
||||
autoSchemaFile: join(process.cwd(), 'src/infrastructure/schema.gql'),
|
||||
EventEmitterModule.forRoot(),
|
||||
RequestContextModule,
|
||||
SlonikModule.forRoot({
|
||||
connectionUri: postgresConnectionUri,
|
||||
}),
|
||||
UnitOfWorkModule,
|
||||
NestEventModule,
|
||||
ConsoleModule,
|
||||
CqrsModule,
|
||||
|
||||
// Modules
|
||||
UserModule,
|
||||
WalletModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
providers: [...interceptors],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
23
src/configs/app.routes.ts
Normal file
23
src/configs/app.routes.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Application routes with its version
|
||||
* https://github.com/Sairyss/backend-best-practices#api-versioning
|
||||
*/
|
||||
|
||||
// Root
|
||||
const usersRoot = 'users';
|
||||
const walletsRoot = 'wallets';
|
||||
|
||||
// Api Versions
|
||||
const v1 = 'v1';
|
||||
|
||||
export const routesV1 = {
|
||||
version: v1,
|
||||
user: {
|
||||
root: usersRoot,
|
||||
delete: `/${usersRoot}/:id`,
|
||||
},
|
||||
wallet: {
|
||||
root: walletsRoot,
|
||||
delete: `/${walletsRoot}/:id`,
|
||||
},
|
||||
};
|
||||
15
src/configs/database.config.ts
Normal file
15
src/configs/database.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { get } from 'env-var';
|
||||
import '../libs/utils/dotenv';
|
||||
|
||||
// https://github.com/Sairyss/backend-best-practices#configuration
|
||||
|
||||
export const databaseConfig = {
|
||||
type: 'postgres',
|
||||
host: get('DB_HOST').required().asString(),
|
||||
port: get('DB_PORT').required().asIntPositive(),
|
||||
username: get('DB_USERNAME').required().asString(),
|
||||
password: get('DB_PASSWORD').required().asString(),
|
||||
database: get('DB_NAME').required().asString(),
|
||||
};
|
||||
|
||||
export const postgresConnectionUri = `postgres://${databaseConfig.username}:${databaseConfig.password}@${databaseConfig.host}/${databaseConfig.database}`;
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Application routes with its version
|
||||
* https://github.com/Sairyss/backend-best-practices#api-versioning
|
||||
*/
|
||||
const usersRoot = '/users';
|
||||
export const routesV1 = {
|
||||
version: 'v1',
|
||||
user: {
|
||||
root: usersRoot,
|
||||
delete: `${usersRoot}/:id`,
|
||||
},
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { typeormConfig } from './ormconfig';
|
||||
|
||||
const database = {
|
||||
...typeormConfig,
|
||||
entities: ['src/**/*.orm-entity.ts'],
|
||||
migrationsTableName: 'migrations',
|
||||
migrations: ['src/**/migrations/*.ts'],
|
||||
seeds: ['src/**/seeding/**/*.seeder.ts'],
|
||||
factories: ['src/**/factories/**/*.ts'],
|
||||
cli: {
|
||||
migrationsDir: `src/infrastructure/database/migrations`,
|
||||
},
|
||||
};
|
||||
|
||||
export = database;
|
||||
@@ -1,31 +0,0 @@
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { config } from 'dotenv';
|
||||
import { get } from 'env-var';
|
||||
|
||||
// https://github.com/Sairyss/backend-best-practices#configuration
|
||||
|
||||
// Initializing dotenv
|
||||
config();
|
||||
|
||||
export const typeormConfig: TypeOrmModuleOptions = {
|
||||
type: 'postgres',
|
||||
host: get('DB_HOST')
|
||||
.required()
|
||||
.asString(),
|
||||
port: get('DB_PORT')
|
||||
.required()
|
||||
.asIntPositive(),
|
||||
username: get('DB_USERNAME')
|
||||
.required()
|
||||
.asString(),
|
||||
password: get('DB_PASSWORD')
|
||||
.required()
|
||||
.asString(),
|
||||
database: get('DB_NAME')
|
||||
.required()
|
||||
.asString(),
|
||||
entities: [],
|
||||
autoLoadEntities: true,
|
||||
connectTimeoutMS: 2000,
|
||||
logging: ['error', 'migration', 'schema'],
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Database migration for schema changes
|
||||
* https://github.com/Sairyss/backend-best-practices#managing-schema-changes
|
||||
*/
|
||||
export class CreateTables1631645442017 implements MigrationInterface {
|
||||
name = 'CreateTables1631645442017';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" character varying NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "email" character varying NOT NULL, "country" character varying NOT NULL, "postalCode" character varying NOT NULL, "street" character varying NOT NULL, "role" character varying NOT NULL, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"), CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class Wallet1632009660457 implements MigrationInterface {
|
||||
name = 'Wallet1632009660457';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "wallet" ("id" character varying NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "balance" integer NOT NULL DEFAULT '0', "userId" character varying NOT NULL, CONSTRAINT "UQ_35472b1fe48b6330cd349709564" UNIQUE ("userId"), CONSTRAINT "PK_bec464dd8d54c39c54fd32e2334" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "wallet"`);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Global, Logger, Module } from '@nestjs/common';
|
||||
import { UnitOfWork } from './unit-of-work';
|
||||
|
||||
const unitOfWorkSingleton = new UnitOfWork(new Logger());
|
||||
|
||||
const unitOfWorkSingletonProvider = {
|
||||
provide: UnitOfWork,
|
||||
useFactory: () => unitOfWorkSingleton,
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [],
|
||||
providers: [unitOfWorkSingletonProvider],
|
||||
exports: [UnitOfWork],
|
||||
})
|
||||
export class UnitOfWorkModule {}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { TypeormUnitOfWork } from '@src/libs/ddd/infrastructure/database/base-classes/typeorm-unit-of-work';
|
||||
import { UserOrmEntity } from '@modules/user/database/user.orm-entity';
|
||||
import { UserRepository } from '@modules/user/database/user.repository';
|
||||
import { WalletOrmEntity } from '@modules/wallet/database/wallet.orm-entity';
|
||||
import { WalletRepository } from '@modules/wallet/database/wallet.repository';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class UnitOfWork extends TypeormUnitOfWork {
|
||||
// Add new repositories below to use this generic UnitOfWork
|
||||
|
||||
// Convert TypeOrm Repository to a Domain Repository
|
||||
getUserRepository(correlationId: string): UserRepository {
|
||||
return new UserRepository(
|
||||
this.getOrmRepository(UserOrmEntity, correlationId),
|
||||
).setCorrelationId(correlationId);
|
||||
}
|
||||
|
||||
getWalletRepository(correlationId: string): WalletRepository {
|
||||
return new WalletRepository(
|
||||
this.getOrmRepository(WalletOrmEntity, correlationId),
|
||||
).setCorrelationId(correlationId);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
NestInterceptor,
|
||||
// To avoid confusion between internal app exceptions and NestJS exceptions
|
||||
ConflictException as NestConflictException,
|
||||
NotFoundException as NestNotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import {
|
||||
ExceptionBase,
|
||||
ConflictException,
|
||||
NotFoundException,
|
||||
} from '@libs/exceptions';
|
||||
|
||||
export class ExceptionInterceptor implements NestInterceptor {
|
||||
intercept(
|
||||
_context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<ExceptionBase> {
|
||||
return next.handle().pipe(
|
||||
catchError(err => {
|
||||
/**
|
||||
* Custom exceptions are converted to nest.js exceptions.
|
||||
* This way we are not tied to a framework or HTTP protocol.
|
||||
*/
|
||||
if (err instanceof NotFoundException) {
|
||||
throw new NestNotFoundException(err.message);
|
||||
}
|
||||
if (err instanceof ConflictException) {
|
||||
throw new NestConflictException(err.message);
|
||||
}
|
||||
return throwError(err);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
# ------------------------------------------------------
|
||||
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
||||
# ------------------------------------------------------
|
||||
|
||||
type IdResponse {
|
||||
id: String!
|
||||
}
|
||||
|
||||
type UserResponse {
|
||||
email: String!
|
||||
country: String!
|
||||
postalCode: String!
|
||||
street: String!
|
||||
}
|
||||
|
||||
type Query {
|
||||
findUsers(input: FindUsersRequest!): [UserResponse!]!
|
||||
}
|
||||
|
||||
input FindUsersRequest {
|
||||
country: String
|
||||
postalCode: String
|
||||
street: String
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
create(input: CreateUserRequest!): IdResponse!
|
||||
}
|
||||
|
||||
input CreateUserRequest {
|
||||
email: String!
|
||||
country: String!
|
||||
postalCode: String!
|
||||
street: String!
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/* Creating interfaces like this may be useful if you need to share types with
|
||||
a front end web/mobile application, microservices, or other TypeScript APIs.
|
||||
You can share interfaces as a git submodule, a npm package, a library or in a monorepo, etc.
|
||||
*/
|
||||
export interface CreateUser {
|
||||
readonly email: string;
|
||||
readonly country: string;
|
||||
readonly postalCode: string;
|
||||
readonly street: string;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface FindUsers {
|
||||
readonly country: string;
|
||||
readonly postalCode: string;
|
||||
readonly street: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ModelBase } from '../../../libs/ddd/interface-adapters/interfaces/model.base.interface';
|
||||
|
||||
export interface User extends ModelBase {
|
||||
email: string;
|
||||
country: string;
|
||||
postalCode: string;
|
||||
street: string;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
// TODO: create an interface
|
||||
31
src/libs/api/api-error.response.ts
Normal file
31
src/libs/api/api-error.response.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ApiErrorResponse {
|
||||
@ApiProperty({ example: 400 })
|
||||
readonly statusCode: number;
|
||||
|
||||
@ApiProperty({ example: 'Validation Error' })
|
||||
readonly message: string;
|
||||
|
||||
@ApiProperty({ example: 'Bad Request' })
|
||||
readonly error: string;
|
||||
|
||||
@ApiProperty({ example: 'YevPQs' })
|
||||
readonly correlationId: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: ['incorrect email'],
|
||||
description: 'Optional list of sub-errors',
|
||||
nullable: true,
|
||||
required: false,
|
||||
})
|
||||
readonly subErrors?: string[];
|
||||
|
||||
constructor(body: ApiErrorResponse) {
|
||||
this.statusCode = body.statusCode;
|
||||
this.message = body.message;
|
||||
this.error = body.error;
|
||||
this.correlationId = body.correlationId;
|
||||
this.subErrors = body.subErrors;
|
||||
}
|
||||
}
|
||||
10
src/libs/api/id.response.dto.ts
Normal file
10
src/libs/api/id.response.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class IdResponse {
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@ApiProperty({ example: '2cdc8ab1-6d50-49cc-ba14-54e4ac7ec231' })
|
||||
readonly id: string;
|
||||
}
|
||||
25
src/libs/api/paginated-query.request.dto.ts
Normal file
25
src/libs/api/paginated-query.request.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||
|
||||
export class PaginatedQueryRequestDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(99999)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({
|
||||
example: 10,
|
||||
description: 'Specifies a limit of returned records',
|
||||
required: false,
|
||||
})
|
||||
readonly limit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(99999)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ example: 0, description: 'Page number', required: false })
|
||||
readonly page?: number;
|
||||
}
|
||||
22
src/libs/api/paginated.response.base.ts
Normal file
22
src/libs/api/paginated.response.base.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Paginated } from '../ddd';
|
||||
|
||||
export abstract class PaginatedResponseDto<T> extends Paginated<T> {
|
||||
@ApiProperty({
|
||||
example: 5312,
|
||||
description: 'Total number of items',
|
||||
})
|
||||
readonly count: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 10,
|
||||
description: 'Number of items per page',
|
||||
})
|
||||
readonly limit: number;
|
||||
|
||||
@ApiProperty({ example: 0, description: 'Page number' })
|
||||
readonly page: number;
|
||||
|
||||
@ApiProperty({ isArray: true })
|
||||
abstract data: T[];
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { BaseEntityProps } from '@libs/ddd/domain/base-classes/entity.base';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IdResponse } from '../dtos/id.response.dto';
|
||||
import { IdResponse } from './id.response.dto';
|
||||
|
||||
export interface BaseResponseProps {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Most of our response objects will have properties like
|
||||
@@ -8,10 +13,10 @@ import { IdResponse } from '../dtos/id.response.dto';
|
||||
* separate class and extend it to avoid duplication.
|
||||
*/
|
||||
export class ResponseBase extends IdResponse {
|
||||
constructor(entity: BaseEntityProps) {
|
||||
super(entity.id.value);
|
||||
this.createdAt = entity.createdAt.value.toISOString();
|
||||
this.updatedAt = entity.updatedAt.value.toISOString();
|
||||
constructor(props: BaseResponseProps) {
|
||||
super(props.id);
|
||||
this.createdAt = new Date(props.createdAt).toISOString();
|
||||
this.updatedAt = new Date(props.updatedAt).toISOString();
|
||||
}
|
||||
|
||||
@ApiProperty({ example: '2020-11-24T17:43:15.970Z' })
|
||||
44
src/libs/application/context/AppRequestContext.ts
Normal file
44
src/libs/application/context/AppRequestContext.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { RequestContext } from 'nestjs-request-context';
|
||||
import { DatabaseTransactionConnection } from 'slonik';
|
||||
|
||||
/**
|
||||
* Setting some isolated context for each request.
|
||||
*/
|
||||
|
||||
export class AppRequestContext extends RequestContext {
|
||||
requestId: string;
|
||||
transactionConnection?: DatabaseTransactionConnection; // For global transactions
|
||||
}
|
||||
|
||||
export class RequestContextService {
|
||||
static getContext(): AppRequestContext {
|
||||
const ctx: AppRequestContext = RequestContext.currentContext.req;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
static setRequestId(id: string): void {
|
||||
const ctx = this.getContext();
|
||||
ctx.requestId = id;
|
||||
}
|
||||
|
||||
static getRequestId(): string {
|
||||
return this.getContext().requestId;
|
||||
}
|
||||
|
||||
static getTransactionConnection(): DatabaseTransactionConnection | undefined {
|
||||
const ctx = this.getContext();
|
||||
return ctx.transactionConnection;
|
||||
}
|
||||
|
||||
static setTransactionConnection(
|
||||
transactionConnection?: DatabaseTransactionConnection,
|
||||
): void {
|
||||
const ctx = this.getContext();
|
||||
ctx.transactionConnection = transactionConnection;
|
||||
}
|
||||
|
||||
static cleanTransactionConnection(): void {
|
||||
const ctx = this.getContext();
|
||||
ctx.transactionConnection = undefined;
|
||||
}
|
||||
}
|
||||
30
src/libs/application/context/ContextInterceptor.ts
Normal file
30
src/libs/application/context/ContextInterceptor.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { RequestContextService } from './AppRequestContext';
|
||||
|
||||
@Injectable()
|
||||
export class ContextInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
/**
|
||||
* Setting an ID in the global context for each request.
|
||||
* This ID can be used as correlation id shown in logs
|
||||
*/
|
||||
const requestId = request?.body?.requestId ?? nanoid(6);
|
||||
|
||||
RequestContextService.setRequestId(requestId);
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
// Perform cleaning if needed
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
60
src/libs/application/interceptors/exception.interceptor.ts
Normal file
60
src/libs/application/interceptors/exception.interceptor.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Logger,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { ExceptionBase } from '@libs/exceptions';
|
||||
import { RequestContextService } from '../context/AppRequestContext';
|
||||
import { ApiErrorResponse } from '@src/libs/api/api-error.response';
|
||||
|
||||
export class ExceptionInterceptor implements NestInterceptor {
|
||||
private readonly logger: Logger = new Logger(ExceptionInterceptor.name);
|
||||
|
||||
intercept(
|
||||
_context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<ExceptionBase> {
|
||||
return next.handle().pipe(
|
||||
catchError((err) => {
|
||||
// Logging for debugging purposes
|
||||
if (err.status >= 400 && err.status < 500) {
|
||||
this.logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] ${err.message}`,
|
||||
);
|
||||
|
||||
const isClassValidatorError =
|
||||
Array.isArray(err?.response?.message) &&
|
||||
typeof err?.response?.error === 'string' &&
|
||||
err.status === 400;
|
||||
// Transforming class-validator errors to a different format
|
||||
if (isClassValidatorError) {
|
||||
err = new BadRequestException(
|
||||
new ApiErrorResponse({
|
||||
statusCode: err.status,
|
||||
message: 'Validation error',
|
||||
error: err?.response?.error,
|
||||
subErrors: err?.response?.message,
|
||||
correlationId: RequestContextService.getRequestId(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Adding request ID to error message
|
||||
if (!err.correlationId) {
|
||||
err.correlationId = RequestContextService.getRequestId();
|
||||
}
|
||||
|
||||
if (err.response) {
|
||||
err.response.correlationId = err.correlationId;
|
||||
}
|
||||
|
||||
return throwError(err);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
241
src/libs/db/sql-repository.base.ts
Normal file
241
src/libs/db/sql-repository.base.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { RequestContextService } from '@libs/application/context/AppRequestContext';
|
||||
import { AggregateRoot, PaginatedQueryParams, Paginated } from '@libs/ddd';
|
||||
import { Mapper } from '@libs/ddd';
|
||||
import { RepositoryPort } from '@libs/ddd';
|
||||
import { ConflictException } from '@libs/exceptions';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { None, Option, Some } from 'oxide.ts';
|
||||
import {
|
||||
DatabasePool,
|
||||
DatabaseTransactionConnection,
|
||||
IdentifierSqlToken,
|
||||
MixedRow,
|
||||
PrimitiveValueExpression,
|
||||
QueryResult,
|
||||
QueryResultRow,
|
||||
sql,
|
||||
SqlSqlToken,
|
||||
UniqueIntegrityConstraintViolationError,
|
||||
} from 'slonik';
|
||||
import { ZodTypeAny, TypeOf, ZodObject } from 'zod';
|
||||
import { LoggerPort } from '../ports/logger.port';
|
||||
import { ObjectLiteral } from '../types';
|
||||
|
||||
export abstract class SqlRepositoryBase<
|
||||
Aggregate extends AggregateRoot<any>,
|
||||
DbModel extends ObjectLiteral,
|
||||
> implements RepositoryPort<Aggregate>
|
||||
{
|
||||
protected abstract tableName: string;
|
||||
|
||||
protected abstract schema: ZodObject<any>;
|
||||
|
||||
protected constructor(
|
||||
private readonly _pool: DatabasePool,
|
||||
protected readonly mapper: Mapper<Aggregate, DbModel>,
|
||||
protected readonly eventEmitter: EventEmitter2,
|
||||
protected readonly logger: LoggerPort,
|
||||
) {}
|
||||
|
||||
async findOneById(id: string): Promise<Option<Aggregate>> {
|
||||
const query = sql.type(this.schema)`SELECT * FROM ${sql.identifier([
|
||||
this.tableName,
|
||||
])} WHERE id = ${id}`;
|
||||
|
||||
const result = await this.pool.query(query);
|
||||
return result.rows[0] ? Some(this.mapper.toDomain(result.rows[0])) : None;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Aggregate[]> {
|
||||
const query = sql.type(this.schema)`SELECT * FROM ${sql.identifier([
|
||||
this.tableName,
|
||||
])}`;
|
||||
|
||||
const result = await this.pool.query(query);
|
||||
|
||||
return result.rows.map(this.mapper.toDomain);
|
||||
}
|
||||
|
||||
async findAllPaginated(
|
||||
params: PaginatedQueryParams,
|
||||
): Promise<Paginated<Aggregate>> {
|
||||
const query = sql.type(this.schema)`
|
||||
SELECT * FROM ${sql.identifier([this.tableName])}
|
||||
LIMIT ${params.limit}
|
||||
OFFSET ${params.offset}
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query);
|
||||
|
||||
const entities = result.rows.map(this.mapper.toDomain);
|
||||
return new Paginated({
|
||||
data: entities,
|
||||
count: result.rowCount,
|
||||
limit: params.limit,
|
||||
page: params.page,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(entity: Aggregate): Promise<boolean> {
|
||||
entity.validate();
|
||||
const query = sql`DELETE FROM ${sql.identifier([
|
||||
this.tableName,
|
||||
])} WHERE id = ${entity.id}`;
|
||||
|
||||
this.logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] deleting entities ${
|
||||
(entity.id, this.tableName)
|
||||
}`,
|
||||
);
|
||||
|
||||
const result = await this.pool.query(query);
|
||||
|
||||
await entity.publishEvents(this.logger, this.eventEmitter);
|
||||
|
||||
return result.rowCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts an entity to a database
|
||||
* (also publishes domain events and waits for completion)
|
||||
*/
|
||||
async insert(entity: Aggregate | Aggregate[]): Promise<void> {
|
||||
const entities = Array.isArray(entity) ? entity : [entity];
|
||||
|
||||
const records = entities.map(this.mapper.toPersistance);
|
||||
|
||||
const query = this.generateInsertQuery(records);
|
||||
|
||||
try {
|
||||
await this.writeQuery(query, entities);
|
||||
} catch (error) {
|
||||
if (error instanceof UniqueIntegrityConstraintViolationError) {
|
||||
this.logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] ${
|
||||
(error.originalError as any).detail
|
||||
}`,
|
||||
);
|
||||
throw new ConflictException('Record already exists', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method for write queries when you need to mutate an entity.
|
||||
* Executes entity validation, publishes events,
|
||||
* and does some debug logging.
|
||||
* For read queries use `this.pool` directly
|
||||
*/
|
||||
protected async writeQuery<T>(
|
||||
sql: SqlSqlToken<
|
||||
T extends MixedRow ? T : Record<string, PrimitiveValueExpression>
|
||||
>,
|
||||
entity: Aggregate | Aggregate[],
|
||||
): Promise<
|
||||
QueryResult<
|
||||
T extends MixedRow
|
||||
? T extends ZodTypeAny
|
||||
? TypeOf<ZodTypeAny & MixedRow & T>
|
||||
: T
|
||||
: T
|
||||
>
|
||||
> {
|
||||
const entities = Array.isArray(entity) ? entity : [entity];
|
||||
entities.forEach((entity) => entity.validate());
|
||||
const entityIds = entities.map((e) => e.id);
|
||||
|
||||
this.logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] writing ${
|
||||
entities.length
|
||||
} entities to "${this.tableName}" table: ${entityIds}`,
|
||||
);
|
||||
|
||||
const result = await this.pool.query(sql);
|
||||
|
||||
await Promise.all(
|
||||
entities.map((entity) =>
|
||||
entity.publishEvents(this.logger, this.eventEmitter),
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to generate insert query for any objects.
|
||||
* Use carefully and don't accept non-validated objects.
|
||||
*
|
||||
* Passing object with { name: string, email: string } will generate
|
||||
* a query: INSERT INTO "table" (name, email) VALUES ($1, $2)
|
||||
*/
|
||||
protected generateInsertQuery(
|
||||
models: DbModel[],
|
||||
): SqlSqlToken<QueryResultRow> {
|
||||
// TODO: generate query from an entire array to insert multiple records at once
|
||||
const entries = Object.entries(models[0]);
|
||||
const values: any = [];
|
||||
const propertyNames: IdentifierSqlToken[] = [];
|
||||
|
||||
entries.forEach((entry) => {
|
||||
if (entry[0] && entry[1] !== undefined) {
|
||||
propertyNames.push(sql.identifier([entry[0]]));
|
||||
if (entry[1] instanceof Date) {
|
||||
values.push(sql.timestamp(entry[1]));
|
||||
} else {
|
||||
values.push(entry[1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const query = sql`INSERT INTO ${sql.identifier([
|
||||
this.tableName,
|
||||
])} (${sql.join(propertyNames, sql`, `)}) VALUES (${sql.join(
|
||||
values,
|
||||
sql`, `,
|
||||
)})`;
|
||||
|
||||
const parsedQuery = query;
|
||||
return parsedQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* start a global transaction to save
|
||||
* results of all event handlers in one operation
|
||||
*/
|
||||
public async transaction<T>(handler: () => Promise<T>): Promise<T> {
|
||||
return this.pool.transaction(async (connection) => {
|
||||
this.logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] transaction started`,
|
||||
);
|
||||
if (!RequestContextService.getTransactionConnection()) {
|
||||
RequestContextService.setTransactionConnection(connection);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler();
|
||||
this.logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] transaction committed`,
|
||||
);
|
||||
return result;
|
||||
} catch (e) {
|
||||
this.logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] transaction aborted`,
|
||||
);
|
||||
throw e;
|
||||
} finally {
|
||||
RequestContextService.cleanTransactionConnection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database pool.
|
||||
* If global request transaction is started,
|
||||
* returns a transaction pool.
|
||||
*/
|
||||
protected get pool(): DatabasePool | DatabaseTransactionConnection {
|
||||
return (
|
||||
RequestContextService.getContext().transactionConnection ?? this._pool
|
||||
);
|
||||
}
|
||||
}
|
||||
40
src/libs/ddd/aggregate-root.base.ts
Normal file
40
src/libs/ddd/aggregate-root.base.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { DomainEvent } from './domain-event.base';
|
||||
import { Entity } from './entity.base';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { LoggerPort } from '@libs/ports/logger.port';
|
||||
import { RequestContextService } from '../application/context/AppRequestContext';
|
||||
|
||||
export abstract class AggregateRoot<EntityProps> extends Entity<EntityProps> {
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
get domainEvents(): DomainEvent[] {
|
||||
return this._domainEvents;
|
||||
}
|
||||
|
||||
protected addEvent(domainEvent: DomainEvent): void {
|
||||
this._domainEvents.push(domainEvent);
|
||||
}
|
||||
|
||||
public clearEvents(): void {
|
||||
this._domainEvents = [];
|
||||
}
|
||||
|
||||
public async publishEvents(
|
||||
logger: LoggerPort,
|
||||
eventEmitter: EventEmitter2,
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
this.domainEvents.map(async (event) => {
|
||||
logger.debug(
|
||||
`[${RequestContextService.getRequestId()}] "${
|
||||
event.constructor.name
|
||||
}" event published for aggregate ${this.constructor.name} : ${
|
||||
this.id
|
||||
}`,
|
||||
);
|
||||
return eventEmitter.emitAsync(event.constructor.name, event);
|
||||
}),
|
||||
);
|
||||
this.clearEvents();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { ArgumentNotProvidedException } from '../../../exceptions';
|
||||
import { RequestContextService } from '@libs/application/context/AppRequestContext';
|
||||
import { v4 } from 'uuid';
|
||||
import { ArgumentNotProvidedException } from '../exceptions';
|
||||
import { Guard } from '../guard';
|
||||
import { UUID } from '../value-objects/uuid.value-object';
|
||||
|
||||
export type CommandProps<T> = Omit<T, 'correlationId' | 'id'> &
|
||||
Partial<Command>;
|
||||
@@ -13,12 +13,12 @@ export class Command {
|
||||
*/
|
||||
public readonly id: string;
|
||||
|
||||
/** ID for correlation purposes (for UnitOfWork, for commands that
|
||||
* arrive from other microservices,logs correlation etc). */
|
||||
/** ID for correlation purposes (for commands that
|
||||
* arrive from other microservices,logs correlation, etc). */
|
||||
public readonly correlationId: string;
|
||||
|
||||
/**
|
||||
* Causation id to reconstruct execution ordering if needed
|
||||
* Causation id to reconstruct execution order if needed
|
||||
*/
|
||||
public readonly causationId?: string;
|
||||
|
||||
@@ -28,7 +28,8 @@ export class Command {
|
||||
'Command props should not be empty',
|
||||
);
|
||||
}
|
||||
this.correlationId = props.correlationId || nanoid(8);
|
||||
this.id = props.id || UUID.generate().unpack();
|
||||
const ctx = RequestContextService.getContext();
|
||||
this.correlationId = props.correlationId || ctx.requestId;
|
||||
this.id = props.id || v4();
|
||||
}
|
||||
}
|
||||
46
src/libs/ddd/domain-event.base.ts
Normal file
46
src/libs/ddd/domain-event.base.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ArgumentNotProvidedException } from '../exceptions';
|
||||
import { Guard } from '../guard';
|
||||
import { v4 } from 'uuid';
|
||||
import { RequestContextService } from '@libs/application/context/AppRequestContext';
|
||||
|
||||
export type DomainEventProps<T> = Omit<
|
||||
T,
|
||||
'id' | 'timestamp' | 'correlationId' | 'eventName'
|
||||
> & {
|
||||
aggregateId: string;
|
||||
correlationId?: string;
|
||||
causationId?: string;
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
export abstract class DomainEvent {
|
||||
public readonly id: string;
|
||||
|
||||
/** Aggregate ID where domain event occurred */
|
||||
public readonly aggregateId: string;
|
||||
|
||||
/** Timestamp when this domain event occurred */
|
||||
public readonly timestamp: number;
|
||||
|
||||
/** ID for correlation purposes (for Integration Events,logs correlation, etc).
|
||||
*/
|
||||
public correlationId: string;
|
||||
|
||||
/**
|
||||
* Causation id used to reconstruct execution order if needed
|
||||
*/
|
||||
public causationId?: string;
|
||||
|
||||
constructor(props: DomainEventProps<unknown>) {
|
||||
if (Guard.isEmpty(props)) {
|
||||
throw new ArgumentNotProvidedException(
|
||||
'DomainEvent props should not be empty',
|
||||
);
|
||||
}
|
||||
this.id = v4();
|
||||
this.aggregateId = props.aggregateId;
|
||||
this.timestamp = props.timestamp || Date.now();
|
||||
this.correlationId =
|
||||
props.correlationId || RequestContextService.getRequestId();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { DomainEvent } from '../domain-events/domain-event.base';
|
||||
import { DomainEvents } from '../domain-events/domain-events';
|
||||
import { Entity } from './entity.base';
|
||||
|
||||
export abstract class AggregateRoot<EntityProps> extends Entity<EntityProps> {
|
||||
private _domainEvents: DomainEvent[] = [];
|
||||
|
||||
get domainEvents(): DomainEvent[] {
|
||||
return this._domainEvents;
|
||||
}
|
||||
|
||||
protected addEvent(domainEvent: DomainEvent): void {
|
||||
this._domainEvents.push(domainEvent);
|
||||
DomainEvents.prepareForPublish(this);
|
||||
}
|
||||
|
||||
public clearEvents(): void {
|
||||
this._domainEvents = [];
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Result } from 'oxide.ts/dist';
|
||||
import { UnitOfWorkPort } from '../ports/unit-of-work.port';
|
||||
import { Command } from './command.base';
|
||||
|
||||
export abstract class CommandHandlerBase<
|
||||
CommandHandlerReturnType = unknown,
|
||||
CommandHandlerError extends Error = Error
|
||||
> {
|
||||
constructor(protected readonly unitOfWork: UnitOfWorkPort) {}
|
||||
|
||||
// Forces all command handlers to implement a handle method
|
||||
abstract handle(
|
||||
command: Command,
|
||||
): Promise<Result<CommandHandlerReturnType, CommandHandlerError>>;
|
||||
|
||||
/**
|
||||
* Execute a command as a UnitOfWork to include
|
||||
* everything in a single atomic database transaction
|
||||
*/
|
||||
execute(
|
||||
command: Command,
|
||||
): Promise<Result<CommandHandlerReturnType, CommandHandlerError>> {
|
||||
return this.unitOfWork.execute(command.correlationId, async () =>
|
||||
this.handle(command),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Result } from 'oxide.ts/dist';
|
||||
|
||||
export abstract class Query {}
|
||||
|
||||
export abstract class QueryHandlerBase {
|
||||
// For consistency with a CommandHandlerBase and DomainEventHandler
|
||||
abstract handle(query: Query): Promise<Result<unknown, Error>>;
|
||||
|
||||
execute(query: Query): Promise<Result<unknown, Error>> {
|
||||
return this.handle(query);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { DomainEvent, DomainEventClass, DomainEvents } from '.';
|
||||
|
||||
export abstract class DomainEventHandler {
|
||||
constructor(private readonly event: DomainEventClass) {}
|
||||
|
||||
abstract handle(event: DomainEvent): Promise<void>;
|
||||
|
||||
public listen(): void {
|
||||
DomainEvents.subscribe(this.event, this);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { ArgumentNotProvidedException } from '../../../exceptions';
|
||||
import { Guard } from '../guard';
|
||||
import { UUID } from '../value-objects/uuid.value-object';
|
||||
|
||||
export type DomainEventProps<T> = Omit<
|
||||
T,
|
||||
'id' | 'correlationId' | 'dateOccurred'
|
||||
> &
|
||||
Omit<DomainEvent, 'id' | 'correlationId' | 'dateOccurred'> & {
|
||||
correlationId?: string;
|
||||
dateOccurred?: number;
|
||||
};
|
||||
|
||||
export abstract class DomainEvent {
|
||||
public readonly id: string;
|
||||
|
||||
/** Aggregate ID where domain event occurred */
|
||||
public readonly aggregateId: string;
|
||||
|
||||
/** Date when this domain event occurred */
|
||||
public readonly dateOccurred: number;
|
||||
|
||||
/** ID for correlation purposes (for UnitOfWork, Integration Events,logs correlation etc).
|
||||
* This ID is set automatically in a publisher.
|
||||
*/
|
||||
public correlationId: string;
|
||||
|
||||
/**
|
||||
* Causation id to reconstruct execution ordering if needed
|
||||
*/
|
||||
public causationId?: string;
|
||||
|
||||
constructor(props: DomainEventProps<unknown>) {
|
||||
if (Guard.isEmpty(props)) {
|
||||
throw new ArgumentNotProvidedException(
|
||||
'DomainEvent props should not be empty',
|
||||
);
|
||||
}
|
||||
this.id = UUID.generate().unpack();
|
||||
this.aggregateId = props.aggregateId;
|
||||
this.dateOccurred = props.dateOccurred || Date.now();
|
||||
if (props.correlationId) this.correlationId = props.correlationId;
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { AggregateRoot } from '../base-classes/aggregate-root.base';
|
||||
import { Logger } from '../ports/logger.port';
|
||||
import { DomainEvent, DomainEventHandler } from '.';
|
||||
import { final } from '../../../decorators/final.decorator';
|
||||
import { ID } from '../value-objects/id.value-object';
|
||||
|
||||
type EventName = string;
|
||||
|
||||
export type DomainEventClass = new (...args: never[]) => DomainEvent;
|
||||
|
||||
@final
|
||||
export class DomainEvents {
|
||||
private static subscribers: Map<EventName, DomainEventHandler[]> = new Map();
|
||||
|
||||
private static aggregates: AggregateRoot<unknown>[] = [];
|
||||
|
||||
public static subscribe<T extends DomainEventHandler>(
|
||||
event: DomainEventClass,
|
||||
eventHandler: T,
|
||||
): void {
|
||||
const eventName: EventName = event.name;
|
||||
if (!this.subscribers.has(eventName)) {
|
||||
this.subscribers.set(eventName, []);
|
||||
}
|
||||
this.subscribers.get(eventName)?.push(eventHandler);
|
||||
}
|
||||
|
||||
public static prepareForPublish(aggregate: AggregateRoot<unknown>): void {
|
||||
const aggregateFound = !!this.findAggregateByID(aggregate.id);
|
||||
if (!aggregateFound) {
|
||||
this.aggregates.push(aggregate);
|
||||
}
|
||||
}
|
||||
|
||||
public static async publishEvents(
|
||||
id: ID,
|
||||
logger: Logger,
|
||||
correlationId?: string,
|
||||
): Promise<void> {
|
||||
const aggregate = this.findAggregateByID(id);
|
||||
|
||||
if (aggregate) {
|
||||
logger.debug(
|
||||
`[${aggregate.domainEvents.map(
|
||||
event => event.constructor.name,
|
||||
)}] published ${aggregate.id.value}`,
|
||||
);
|
||||
await Promise.all(
|
||||
aggregate.domainEvents.map((event: DomainEvent) => {
|
||||
if (correlationId && !event.correlationId) {
|
||||
event.correlationId = correlationId;
|
||||
}
|
||||
return this.publish(event, logger);
|
||||
}),
|
||||
);
|
||||
aggregate.clearEvents();
|
||||
this.removeAggregateFromPublishList(aggregate);
|
||||
}
|
||||
}
|
||||
|
||||
private static findAggregateByID(id: ID): AggregateRoot<unknown> | undefined {
|
||||
for (const aggregate of this.aggregates) {
|
||||
if (aggregate.id.equals(id)) {
|
||||
return aggregate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static removeAggregateFromPublishList(
|
||||
aggregate: AggregateRoot<unknown>,
|
||||
): void {
|
||||
const index = this.aggregates.findIndex(a => a.equals(aggregate));
|
||||
this.aggregates.splice(index, 1);
|
||||
}
|
||||
|
||||
private static async publish(
|
||||
event: DomainEvent,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
const eventName: string = event.constructor.name;
|
||||
|
||||
if (this.subscribers.has(eventName)) {
|
||||
const handlers: DomainEventHandler[] =
|
||||
this.subscribers.get(eventName) || [];
|
||||
await Promise.all(
|
||||
handlers.map(handler => {
|
||||
logger.debug(
|
||||
`[${handler.constructor.name}] handling ${event.constructor.name} ${event.aggregateId}`,
|
||||
);
|
||||
return handler.handle(event);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './domain-event.base';
|
||||
export * from './domain-events';
|
||||
export * from './domain-event-handler.base';
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface EventEmitterPort {
|
||||
emit<T>(event: string, ...args: T[]): void;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { BaseEntityProps } from '../base-classes/entity.base';
|
||||
import { DeepPartial } from '../../../types';
|
||||
import { ID } from '../value-objects/id.value-object';
|
||||
|
||||
/* Most of repositories will probably need generic
|
||||
save/find/delete operations, so it's easier
|
||||
to have some shared interfaces.
|
||||
More specific interfaces should be defined
|
||||
in a respective module/use case.
|
||||
*/
|
||||
|
||||
export type QueryParams<EntityProps> = DeepPartial<
|
||||
BaseEntityProps & EntityProps
|
||||
>;
|
||||
|
||||
export interface Save<Entity> {
|
||||
save(entity: Entity): Promise<Entity>;
|
||||
}
|
||||
|
||||
export interface SaveMultiple<Entity> {
|
||||
saveMultiple(entities: Entity[]): Promise<Entity[]>;
|
||||
}
|
||||
|
||||
export interface FindOne<Entity, EntityProps> {
|
||||
findOneOrThrow(params: QueryParams<EntityProps>): Promise<Entity>;
|
||||
}
|
||||
|
||||
export interface FindOneById<Entity> {
|
||||
findOneByIdOrThrow(id: ID | string): Promise<Entity>;
|
||||
}
|
||||
|
||||
export interface FindMany<Entity, EntityProps> {
|
||||
findMany(params: QueryParams<EntityProps>): Promise<Entity[]>;
|
||||
}
|
||||
|
||||
export interface OrderBy {
|
||||
[key: number]: -1 | 1;
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
export interface FindManyPaginatedParams<EntityProps> {
|
||||
params?: QueryParams<EntityProps>;
|
||||
pagination?: PaginationMeta;
|
||||
orderBy?: OrderBy;
|
||||
}
|
||||
|
||||
export interface DataWithPaginationMeta<T> {
|
||||
data: T;
|
||||
count: number;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
export interface FindManyPaginated<Entity, EntityProps> {
|
||||
findManyPaginated(
|
||||
options: FindManyPaginatedParams<EntityProps>,
|
||||
): Promise<DataWithPaginationMeta<Entity[]>>;
|
||||
}
|
||||
|
||||
export interface DeleteOne<Entity> {
|
||||
delete(entity: Entity): Promise<Entity>;
|
||||
}
|
||||
|
||||
export interface RepositoryPort<Entity, EntityProps>
|
||||
extends Save<Entity>,
|
||||
FindOne<Entity, EntityProps>,
|
||||
FindOneById<Entity>,
|
||||
FindMany<Entity, EntityProps>,
|
||||
FindManyPaginated<Entity, EntityProps>,
|
||||
DeleteOne<Entity>,
|
||||
SaveMultiple<Entity> {
|
||||
setCorrelationId(correlationId: string): this;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface UnitOfWorkPort {
|
||||
execute<T>(
|
||||
correlationId: string,
|
||||
callback: () => Promise<T>,
|
||||
options?: unknown,
|
||||
): Promise<T>;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ArgumentInvalidException } from '../../../exceptions';
|
||||
import {
|
||||
DomainPrimitive,
|
||||
ValueObject,
|
||||
} from '../base-classes/value-object.base';
|
||||
|
||||
export class DateVO extends ValueObject<Date> {
|
||||
constructor(value: Date | string | number) {
|
||||
const date = new Date(value);
|
||||
super({ value: date });
|
||||
}
|
||||
|
||||
public get value(): Date {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
public static now(): DateVO {
|
||||
return new DateVO(Date.now());
|
||||
}
|
||||
|
||||
protected validate({ value }: DomainPrimitive<Date>): void {
|
||||
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
|
||||
throw new ArgumentInvalidException('Incorrect date');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import {
|
||||
DomainPrimitive,
|
||||
ValueObject,
|
||||
} from '../base-classes/value-object.base';
|
||||
|
||||
export abstract class ID extends ValueObject<string> {
|
||||
constructor(value: string) {
|
||||
super({ value });
|
||||
}
|
||||
|
||||
public get value(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
protected abstract validate({ value }: DomainPrimitive<string>): void;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { v4 as uuidV4, validate } from 'uuid';
|
||||
import { DomainPrimitive } from '../base-classes/value-object.base';
|
||||
import { ArgumentInvalidException } from '../../../exceptions/argument-invalid.exception';
|
||||
import { ID } from './id.value-object';
|
||||
|
||||
export class UUID extends ID {
|
||||
/**
|
||||
*Returns new ID instance with randomly generated ID value
|
||||
* @static
|
||||
* @return {*} {ID}
|
||||
* @memberof ID
|
||||
*/
|
||||
static generate(): UUID {
|
||||
return new UUID(uuidV4());
|
||||
}
|
||||
|
||||
protected validate({ value }: DomainPrimitive<string>): void {
|
||||
if (!validate(value)) {
|
||||
throw new ArgumentInvalidException('Incorrect UUID format');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,23 @@ import {
|
||||
ArgumentNotProvidedException,
|
||||
ArgumentInvalidException,
|
||||
ArgumentOutOfRangeException,
|
||||
} from '../../../exceptions';
|
||||
} from '../exceptions';
|
||||
import { Guard } from '../guard';
|
||||
import { convertPropsToObject } from '../utils';
|
||||
import { DateVO } from '../value-objects/date.value-object';
|
||||
import { ID } from '../value-objects/id.value-object';
|
||||
|
||||
export type AggregateID = string;
|
||||
|
||||
export interface BaseEntityProps {
|
||||
id: ID;
|
||||
createdAt: DateVO;
|
||||
updatedAt: DateVO;
|
||||
id: AggregateID;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateEntityProps<T> {
|
||||
id: ID;
|
||||
id: AggregateID;
|
||||
props: T;
|
||||
createdAt?: DateVO;
|
||||
updatedAt?: DateVO;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export abstract class Entity<EntityProps> {
|
||||
@@ -30,7 +30,7 @@ export abstract class Entity<EntityProps> {
|
||||
}: CreateEntityProps<EntityProps>) {
|
||||
this.setId(id);
|
||||
this.validateProps(props);
|
||||
const now = DateVO.now();
|
||||
const now = new Date();
|
||||
this._createdAt = createdAt || now;
|
||||
this._updatedAt = updatedAt || now;
|
||||
this.props = props;
|
||||
@@ -39,26 +39,31 @@ export abstract class Entity<EntityProps> {
|
||||
|
||||
protected readonly props: EntityProps;
|
||||
|
||||
// ID is set in the entity to support different ID types
|
||||
protected abstract _id: ID;
|
||||
/**
|
||||
* ID is set in the concrete entity implementation to support
|
||||
* different ID types depending on your needs.
|
||||
* For example it could be a UUID for aggregate root,
|
||||
* and shortid / nanoid for child entities.
|
||||
*/
|
||||
protected abstract _id: AggregateID;
|
||||
|
||||
private readonly _createdAt: DateVO;
|
||||
private readonly _createdAt: Date;
|
||||
|
||||
private _updatedAt: DateVO;
|
||||
private _updatedAt: Date;
|
||||
|
||||
get id(): ID {
|
||||
get id(): AggregateID {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
private setId(id: ID): void {
|
||||
private setId(id: AggregateID): void {
|
||||
this._id = id;
|
||||
}
|
||||
|
||||
get createdAt(): DateVO {
|
||||
get createdAt(): Date {
|
||||
return this._createdAt;
|
||||
}
|
||||
|
||||
get updatedAt(): DateVO {
|
||||
get updatedAt(): Date {
|
||||
return this._updatedAt;
|
||||
}
|
||||
|
||||
@@ -67,7 +72,7 @@ export abstract class Entity<EntityProps> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two entities are the same Entity. Checks using ID field.
|
||||
* Checks if two entities are the same Entity by comparing ID field.
|
||||
* @param object Entity
|
||||
*/
|
||||
public equals(object?: Entity<EntityProps>): boolean {
|
||||
@@ -83,7 +88,7 @@ export abstract class Entity<EntityProps> {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.id ? this.id.equals(object.id) : false;
|
||||
return this.id ? this.id === object.id : false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,21 +119,23 @@ export abstract class Entity<EntityProps> {
|
||||
const plainProps = convertPropsToObject(this.props);
|
||||
|
||||
const result = {
|
||||
id: this._id.value,
|
||||
createdAt: this._createdAt.value,
|
||||
updatedAt: this._updatedAt.value,
|
||||
id: this._id,
|
||||
createdAt: this._createdAt,
|
||||
updatedAt: this._updatedAt,
|
||||
...plainProps,
|
||||
};
|
||||
return Object.freeze(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate invariant
|
||||
* There are certain rules that always have to be true (invariants)
|
||||
* for each entity. Validate method is called every time before
|
||||
* saving an entity to the database to make sure those rules are respected.
|
||||
*/
|
||||
public abstract validate(): void;
|
||||
|
||||
private validateProps(props: EntityProps): void {
|
||||
const maxProps = 50;
|
||||
const MAX_PROPS = 50;
|
||||
|
||||
if (Guard.isEmpty(props)) {
|
||||
throw new ArgumentNotProvidedException(
|
||||
@@ -138,9 +145,9 @@ export abstract class Entity<EntityProps> {
|
||||
if (typeof props !== 'object') {
|
||||
throw new ArgumentInvalidException('Entity props should be an object');
|
||||
}
|
||||
if (Object.keys(props).length > maxProps) {
|
||||
if (Object.keys(props as any).length > MAX_PROPS) {
|
||||
throw new ArgumentOutOfRangeException(
|
||||
`Entity props should not have more than ${maxProps} properties`,
|
||||
`Entity props should not have more than ${MAX_PROPS} properties`,
|
||||
);
|
||||
}
|
||||
}
|
||||
7
src/libs/ddd/index.ts
Normal file
7
src/libs/ddd/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './aggregate-root.base';
|
||||
export * from './command.base';
|
||||
export * from './domain-event.base';
|
||||
export * from './entity.base';
|
||||
export * from './mapper.interface';
|
||||
export * from './repository.port';
|
||||
export * from './value-object.base';
|
||||
@@ -1,52 +0,0 @@
|
||||
/* eslint-disable new-cap */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { AggregateRoot } from '@libs/ddd/domain/base-classes/aggregate-root.base';
|
||||
import { CreateEntityProps } from '@libs/ddd/domain/base-classes/entity.base';
|
||||
import { DateVO } from '@libs/ddd/domain/value-objects/date.value-object';
|
||||
import { ID } from '@libs/ddd/domain/value-objects/id.value-object';
|
||||
import { TypeormEntityBase } from './typeorm.entity.base';
|
||||
|
||||
export type OrmEntityProps<OrmEntity> = Omit<
|
||||
OrmEntity,
|
||||
'id' | 'createdAt' | 'updatedAt'
|
||||
>;
|
||||
|
||||
export interface EntityProps<EntityProps> {
|
||||
id: ID;
|
||||
props: EntityProps;
|
||||
}
|
||||
|
||||
export abstract class OrmMapper<
|
||||
Entity extends AggregateRoot<unknown>,
|
||||
OrmEntity
|
||||
> {
|
||||
constructor(
|
||||
private entityConstructor: new (props: CreateEntityProps<any>) => Entity,
|
||||
private ormEntityConstructor: new (props: any) => OrmEntity,
|
||||
) {}
|
||||
|
||||
protected abstract toDomainProps(ormEntity: OrmEntity): EntityProps<unknown>;
|
||||
|
||||
protected abstract toOrmProps(entity: Entity): OrmEntityProps<OrmEntity>;
|
||||
|
||||
toDomainEntity(ormEntity: OrmEntity): Entity {
|
||||
const { id, props } = this.toDomainProps(ormEntity);
|
||||
const ormEntityBase: TypeormEntityBase = (ormEntity as unknown) as TypeormEntityBase;
|
||||
return new this.entityConstructor({
|
||||
id,
|
||||
props,
|
||||
createdAt: new DateVO(ormEntityBase.createdAt),
|
||||
updatedAt: new DateVO(ormEntityBase.updatedAt),
|
||||
});
|
||||
}
|
||||
|
||||
toOrmEntity(entity: Entity): OrmEntity {
|
||||
const props = this.toOrmProps(entity);
|
||||
return new this.ormEntityConstructor({
|
||||
...props,
|
||||
id: entity.id.value,
|
||||
createdAt: entity.createdAt.value,
|
||||
updatedAt: entity.updatedAt.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { UnitOfWorkPort } from '@src/libs/ddd/domain/ports/unit-of-work.port';
|
||||
import { EntityTarget, getConnection, QueryRunner, Repository } from 'typeorm';
|
||||
import { IsolationLevel } from 'typeorm/driver/types/IsolationLevel';
|
||||
import { Logger } from 'src/libs/ddd/domain/ports/logger.port';
|
||||
import { Err, Result } from 'oxide.ts/dist';
|
||||
|
||||
/**
|
||||
* Keep in mind that this is a naive implementation
|
||||
* of a Unit of Work as it only wraps execution into
|
||||
* a transaction. Proper Unit of Work implementation
|
||||
* requires storing all changes in memory first and
|
||||
* then execute a transaction as a singe database call.
|
||||
* Mikro-orm (https://www.npmjs.com/package/mikro-orm)
|
||||
* is a nice ORM for nodejs that can be used instead
|
||||
* of typeorm to have a proper Unit of Work pattern.
|
||||
* Read more about mikro-orm unit of work:
|
||||
* https://mikro-orm.io/docs/unit-of-work/.
|
||||
*/
|
||||
export class TypeormUnitOfWork implements UnitOfWorkPort {
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
private queryRunners: Map<string, QueryRunner> = new Map();
|
||||
|
||||
getQueryRunner(correlationId: string): QueryRunner {
|
||||
const queryRunner = this.queryRunners.get(correlationId);
|
||||
if (!queryRunner) {
|
||||
throw new Error(
|
||||
'Query runner not found. Incorrect correlationId or transaction is not started. To start a transaction wrap operations in a "execute" method.',
|
||||
);
|
||||
}
|
||||
return queryRunner;
|
||||
}
|
||||
|
||||
getOrmRepository<Entity>(
|
||||
entity: EntityTarget<Entity>,
|
||||
correlationId: string,
|
||||
): Repository<Entity> {
|
||||
const queryRunner = this.getQueryRunner(correlationId);
|
||||
return queryRunner.manager.getRepository(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a UnitOfWork.
|
||||
* Database operations wrapped in a `execute` method will run
|
||||
* in a single transactional operation, so everything gets
|
||||
* saved (including changes done by Domain Events) or nothing at all.
|
||||
*/
|
||||
async execute<T>(
|
||||
correlationId: string,
|
||||
callback: () => Promise<T>,
|
||||
options?: { isolationLevel: IsolationLevel },
|
||||
): Promise<T> {
|
||||
if (!correlationId) {
|
||||
throw new Error('Correlation ID must be provided');
|
||||
}
|
||||
this.logger.setContext(`${this.constructor.name}:${correlationId}`);
|
||||
const queryRunner = getConnection().createQueryRunner();
|
||||
this.queryRunners.set(correlationId, queryRunner);
|
||||
this.logger.debug(`[Starting transaction]`);
|
||||
await queryRunner.startTransaction(options?.isolationLevel);
|
||||
// const queryRunner = this.getQueryRunner(correlationId);
|
||||
let result: T | Result<T, Error>;
|
||||
try {
|
||||
result = await callback();
|
||||
if (((result as unknown) as Result<T, Error>)?.isErr()) {
|
||||
await this.rollbackTransaction<T>(
|
||||
correlationId,
|
||||
((result as unknown) as Err<Error, T>).unwrapErr(),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
await this.rollbackTransaction<T>(correlationId, error as Error);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await queryRunner.commitTransaction();
|
||||
} finally {
|
||||
await this.finish(correlationId);
|
||||
}
|
||||
|
||||
this.logger.debug(`[Transaction committed]`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async rollbackTransaction<T>(correlationId: string, error: Error) {
|
||||
const queryRunner = this.getQueryRunner(correlationId);
|
||||
try {
|
||||
await queryRunner.rollbackTransaction();
|
||||
this.logger.debug(
|
||||
`[Transaction rolled back] ${(error as Error).message}`,
|
||||
);
|
||||
} finally {
|
||||
await this.finish(correlationId);
|
||||
}
|
||||
}
|
||||
|
||||
private async finish(correlationId: string): Promise<void> {
|
||||
const queryRunner = this.getQueryRunner(correlationId);
|
||||
try {
|
||||
await queryRunner.release();
|
||||
} finally {
|
||||
this.queryRunners.delete(correlationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { CreateDateColumn, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
export abstract class TypeormEntityBase {
|
||||
constructor(props?: unknown) {
|
||||
if (props) {
|
||||
Object.assign(this, props);
|
||||
}
|
||||
}
|
||||
|
||||
@PrimaryColumn({ update: false })
|
||||
id: string;
|
||||
|
||||
@CreateDateColumn({
|
||||
type: 'timestamptz',
|
||||
update: false,
|
||||
})
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({
|
||||
type: 'timestamptz',
|
||||
})
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { FindConditions, ObjectLiteral, Repository } from 'typeorm';
|
||||
import { ID } from '@libs/ddd/domain/value-objects/id.value-object';
|
||||
import { DomainEvents } from '@libs/ddd/domain/domain-events';
|
||||
import { Logger } from '@libs/ddd/domain/ports/logger.port';
|
||||
import { AggregateRoot } from '@libs/ddd/domain/base-classes/aggregate-root.base';
|
||||
import {
|
||||
QueryParams,
|
||||
FindManyPaginatedParams,
|
||||
RepositoryPort,
|
||||
DataWithPaginationMeta,
|
||||
} from '../../../domain/ports/repository.ports';
|
||||
import { NotFoundException } from '../../../../exceptions';
|
||||
import { OrmMapper } from './orm-mapper.base';
|
||||
|
||||
export type WhereCondition<OrmEntity> =
|
||||
| FindConditions<OrmEntity>[]
|
||||
| FindConditions<OrmEntity>
|
||||
| ObjectLiteral
|
||||
| string;
|
||||
|
||||
export abstract class TypeormRepositoryBase<
|
||||
Entity extends AggregateRoot<unknown>,
|
||||
EntityProps,
|
||||
OrmEntity
|
||||
> implements RepositoryPort<Entity, EntityProps> {
|
||||
protected constructor(
|
||||
protected readonly repository: Repository<OrmEntity>,
|
||||
protected readonly mapper: OrmMapper<Entity, OrmEntity>,
|
||||
protected readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Specify relations to other tables.
|
||||
* For example: `relations = ['user', ...]`
|
||||
*/
|
||||
protected abstract relations: string[];
|
||||
|
||||
protected tableName = this.repository.metadata.tableName;
|
||||
|
||||
protected abstract prepareQuery(
|
||||
params: QueryParams<EntityProps>,
|
||||
): WhereCondition<OrmEntity>;
|
||||
|
||||
async save(entity: Entity): Promise<Entity> {
|
||||
entity.validate(); // Protecting invariant before saving
|
||||
const ormEntity = this.mapper.toOrmEntity(entity);
|
||||
const result = await this.repository.save(ormEntity);
|
||||
await DomainEvents.publishEvents(
|
||||
entity.id,
|
||||
this.logger,
|
||||
this.correlationId,
|
||||
);
|
||||
this.logger.debug(
|
||||
`[${entity.constructor.name}] persisted ${entity.id.value}`,
|
||||
);
|
||||
return this.mapper.toDomainEntity(result);
|
||||
}
|
||||
|
||||
async saveMultiple(entities: Entity[]): Promise<Entity[]> {
|
||||
const ormEntities = entities.map(entity => {
|
||||
entity.validate();
|
||||
return this.mapper.toOrmEntity(entity);
|
||||
});
|
||||
const result = await this.repository.save(ormEntities);
|
||||
await Promise.all(
|
||||
entities.map(entity =>
|
||||
DomainEvents.publishEvents(entity.id, this.logger, this.correlationId),
|
||||
),
|
||||
);
|
||||
this.logger.debug(
|
||||
`[${entities}]: persisted ${entities.map(entity => entity.id)}`,
|
||||
);
|
||||
return result.map(entity => this.mapper.toDomainEntity(entity));
|
||||
}
|
||||
|
||||
async findOne(
|
||||
params: QueryParams<EntityProps> = {},
|
||||
): Promise<Entity | undefined> {
|
||||
const where = this.prepareQuery(params);
|
||||
const found = await this.repository.findOne({
|
||||
where,
|
||||
relations: this.relations,
|
||||
});
|
||||
return found ? this.mapper.toDomainEntity(found) : undefined;
|
||||
}
|
||||
|
||||
async findOneOrThrow(params: QueryParams<EntityProps> = {}): Promise<Entity> {
|
||||
const found = await this.findOne(params);
|
||||
if (!found) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
async findOneByIdOrThrow(id: ID | string): Promise<Entity> {
|
||||
const found = await this.repository.findOne({
|
||||
where: { id: id instanceof ID ? id.value : id },
|
||||
});
|
||||
if (!found) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return this.mapper.toDomainEntity(found);
|
||||
}
|
||||
|
||||
async findMany(params: QueryParams<EntityProps> = {}): Promise<Entity[]> {
|
||||
const result = await this.repository.find({
|
||||
where: this.prepareQuery(params),
|
||||
relations: this.relations,
|
||||
});
|
||||
|
||||
return result.map(item => this.mapper.toDomainEntity(item));
|
||||
}
|
||||
|
||||
async findManyPaginated({
|
||||
params = {},
|
||||
pagination,
|
||||
orderBy,
|
||||
}: FindManyPaginatedParams<EntityProps>): Promise<
|
||||
DataWithPaginationMeta<Entity[]>
|
||||
> {
|
||||
const [data, count] = await this.repository.findAndCount({
|
||||
skip: pagination?.skip,
|
||||
take: pagination?.limit,
|
||||
where: this.prepareQuery(params),
|
||||
order: orderBy,
|
||||
relations: this.relations,
|
||||
});
|
||||
|
||||
const result: DataWithPaginationMeta<Entity[]> = {
|
||||
data: data.map(item => this.mapper.toDomainEntity(item)),
|
||||
count,
|
||||
limit: pagination?.limit,
|
||||
page: pagination?.page,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async delete(entity: Entity): Promise<Entity> {
|
||||
entity.validate();
|
||||
await this.repository.remove(this.mapper.toOrmEntity(entity));
|
||||
await DomainEvents.publishEvents(
|
||||
entity.id,
|
||||
this.logger,
|
||||
this.correlationId,
|
||||
);
|
||||
this.logger.debug(
|
||||
`[${entity.constructor.name}] deleted ${entity.id.value}`,
|
||||
);
|
||||
return entity;
|
||||
}
|
||||
|
||||
protected correlationId?: string;
|
||||
|
||||
setCorrelationId(correlationId: string): this {
|
||||
this.correlationId = correlationId;
|
||||
this.setContext();
|
||||
return this;
|
||||
}
|
||||
|
||||
private setContext() {
|
||||
if (this.correlationId) {
|
||||
this.logger.setContext(`${this.constructor.name}:${this.correlationId}`);
|
||||
} else {
|
||||
this.logger.setContext(this.constructor.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
// TODO: implement logger
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { Id } from '../interfaces/id.interface';
|
||||
|
||||
@ObjectType() // <- only if you are using GraphQL
|
||||
export class IdResponse implements Id {
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@ApiProperty({ example: '2cdc8ab1-6d50-49cc-ba14-54e4ac7ec231' })
|
||||
@Field() // <- only if you are using GraphQL
|
||||
readonly id: string;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface Id {
|
||||
id: string;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface ModelBase {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
11
src/libs/ddd/mapper.interface.ts
Normal file
11
src/libs/ddd/mapper.interface.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Entity } from './entity.base';
|
||||
|
||||
export interface Mapper<
|
||||
DomainEntity extends Entity<any>,
|
||||
DbRecord,
|
||||
Response = any,
|
||||
> {
|
||||
toPersistance(entity: DomainEntity): DbRecord;
|
||||
toDomain(record: any): DomainEntity;
|
||||
toResponse(entity: DomainEntity): Response;
|
||||
}
|
||||
31
src/libs/ddd/query.base.ts
Normal file
31
src/libs/ddd/query.base.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { OrderBy, PaginatedQueryParams } from './repository.port';
|
||||
|
||||
/**
|
||||
* Base class for regular queries
|
||||
*/
|
||||
export abstract class QueryBase {}
|
||||
|
||||
/**
|
||||
* Base class for paginated queries
|
||||
*/
|
||||
export abstract class PaginatedQueryBase extends QueryBase {
|
||||
limit: number;
|
||||
offset: number;
|
||||
orderBy: OrderBy;
|
||||
page: number;
|
||||
|
||||
constructor(props: PaginatedParams<PaginatedQueryBase>) {
|
||||
super();
|
||||
this.limit = props.limit || 20;
|
||||
this.offset = props.page ? props.page * this.limit : 0;
|
||||
this.page = props.page || 0;
|
||||
this.orderBy = props.orderBy || { field: true, param: 'desc' };
|
||||
}
|
||||
}
|
||||
|
||||
// Paginated query parameters
|
||||
export type PaginatedParams<T> = Omit<
|
||||
T,
|
||||
'limit' | 'offset' | 'orderBy' | 'page'
|
||||
> &
|
||||
Partial<Omit<PaginatedQueryParams, 'offset'>>;
|
||||
41
src/libs/ddd/repository.port.ts
Normal file
41
src/libs/ddd/repository.port.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Option } from 'oxide.ts';
|
||||
|
||||
/* Most of repositories will probably need generic
|
||||
save/find/delete operations, so it's easier
|
||||
to have some shared interfaces.
|
||||
More specific queries should be defined
|
||||
in a respective repository.
|
||||
*/
|
||||
|
||||
export class Paginated<T> {
|
||||
readonly count: number;
|
||||
readonly limit: number;
|
||||
readonly page: number;
|
||||
readonly data: T[];
|
||||
|
||||
constructor(props: Paginated<T>) {
|
||||
this.count = props.count;
|
||||
this.limit = props.limit;
|
||||
this.page = props.page;
|
||||
this.data = props.data;
|
||||
}
|
||||
}
|
||||
|
||||
export type OrderBy = { field: string | true; param: 'asc' | 'desc' };
|
||||
|
||||
export type PaginatedQueryParams = {
|
||||
limit: number;
|
||||
page: number;
|
||||
offset: number;
|
||||
orderBy: OrderBy;
|
||||
};
|
||||
|
||||
export interface RepositoryPort<Entity> {
|
||||
insert(entity: Entity): Promise<void>;
|
||||
findOneById(id: string): Promise<Option<Entity>>;
|
||||
findAll(): Promise<Entity[]>;
|
||||
findAllPaginated(params: PaginatedQueryParams): Promise<Paginated<Entity>>;
|
||||
delete(entity: Entity): Promise<boolean>;
|
||||
|
||||
transaction<T>(handler: () => Promise<T>): Promise<T>;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ArgumentNotProvidedException } from '../../../exceptions';
|
||||
import { ArgumentNotProvidedException } from '../exceptions';
|
||||
import { Guard } from '../guard';
|
||||
import { convertPropsToObject } from '../utils';
|
||||
|
||||
/**
|
||||
* Domain Primitive is an object that contains only a single value
|
||||
*/
|
||||
export type Primitives = string | number | boolean;
|
||||
export interface DomainPrimitive<T extends Primitives | Date> {
|
||||
value: T;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ExceptionBase } from './exception.base';
|
||||
import { ExceptionCodes } from './exception.codes';
|
||||
|
||||
/**
|
||||
* Used to indicate that an incorrect argument was provided to a method/function/class constructor
|
||||
*
|
||||
* @class ArgumentInvalidException
|
||||
* @extends {ExceptionBase}
|
||||
*/
|
||||
export class ArgumentInvalidException extends ExceptionBase {
|
||||
readonly code = ExceptionCodes.argumentInvalid;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ExceptionBase } from './exception.base';
|
||||
import { ExceptionCodes } from './exception.codes';
|
||||
|
||||
/**
|
||||
* Used to indicate that an argument was not provided (is empty object/array, null of undefined).
|
||||
*
|
||||
* @class ArgumentNotProvidedException
|
||||
* @extends {ExceptionBase}
|
||||
*/
|
||||
export class ArgumentNotProvidedException extends ExceptionBase {
|
||||
readonly code = ExceptionCodes.argumentNotProvided;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ExceptionBase } from './exception.base';
|
||||
import { ExceptionCodes } from './exception.codes';
|
||||
|
||||
/**
|
||||
* Used to indicate that an argument is out of allowed range
|
||||
* (for example: incorrect string/array length, number not in allowed min/max range etc)
|
||||
*
|
||||
* @class ArgumentOutOfRangeException
|
||||
* @extends {ExceptionBase}
|
||||
*/
|
||||
export class ArgumentOutOfRangeException extends ExceptionBase {
|
||||
readonly code = ExceptionCodes.argumentOutOfRange;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { ExceptionBase } from './exception.base';
|
||||
import { ExceptionCodes } from './exception.codes';
|
||||
|
||||
export class ConflictException extends ExceptionBase {
|
||||
readonly code = ExceptionCodes.conflict;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { RequestContextService } from '@libs/application/context/AppRequestContext';
|
||||
|
||||
export interface SerializedException {
|
||||
message: string;
|
||||
code: string;
|
||||
correlationId: string;
|
||||
stack?: string;
|
||||
cause?: string;
|
||||
metadata?: unknown;
|
||||
/**
|
||||
* ^ Consider adding optional `metadata` object to
|
||||
/**
|
||||
* ^ Consider adding optional `metadata` object to
|
||||
* exceptions (if language doesn't support anything
|
||||
* similar by default) and pass some useful technical
|
||||
* information about the exception when throwing.
|
||||
@@ -20,6 +24,10 @@ export interface SerializedException {
|
||||
* @extends {Error}
|
||||
*/
|
||||
export abstract class ExceptionBase extends Error {
|
||||
abstract code: string;
|
||||
|
||||
public readonly correlationId: string;
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {ObjectLiteral} [metadata={}]
|
||||
@@ -28,13 +36,17 @@ export abstract class ExceptionBase extends Error {
|
||||
* in application's log files. Only include non-sensitive
|
||||
* info that may help with debugging.
|
||||
*/
|
||||
constructor(readonly message: string, readonly metadata?: unknown) {
|
||||
constructor(
|
||||
readonly message: string,
|
||||
readonly cause?: Error,
|
||||
readonly metadata?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
const ctx = RequestContextService.getContext();
|
||||
this.correlationId = ctx.requestId;
|
||||
}
|
||||
|
||||
abstract code: string;
|
||||
|
||||
/**
|
||||
* By default in NodeJS Error objects are not
|
||||
* serialized properly when sending plain objects
|
||||
@@ -47,6 +59,8 @@ export abstract class ExceptionBase extends Error {
|
||||
message: this.message,
|
||||
code: this.code,
|
||||
stack: this.stack,
|
||||
correlationId: this.correlationId,
|
||||
cause: JSON.stringify(this.cause),
|
||||
metadata: this.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
/**
|
||||
* Adding a `code` string with a custom status code for every
|
||||
* Adding a `code` string with a custom status code for every
|
||||
* exception is a good practice, since when that exception
|
||||
* is transferred to another process `instanceof` check
|
||||
* cannot be performed anymore so a `code` string is used instead.
|
||||
* code enum types can be stored in a separate file so they
|
||||
* can be shared and reused on a receiving side
|
||||
*/
|
||||
export enum ExceptionCodes {
|
||||
argumentInvalid = 'GENERIC.ARGUMENT_INVALID',
|
||||
argumentOutOfRange = 'GENERIC.ARGUMENT_OUT_OF_RANGE',
|
||||
argumentNotProvided = 'GENERIC.ARGUMENT_NOT_PROVIDED',
|
||||
notFound = 'GENERIC.NOT_FOUND',
|
||||
conflict = 'GENERIC.CONFLICT',
|
||||
}
|
||||
export const ARGUMENT_INVALID = 'GENERIC.ARGUMENT_INVALID';
|
||||
export const ARGUMENT_OUT_OF_RANGE = 'GENERIC.ARGUMENT_OUT_OF_RANGE';
|
||||
export const ARGUMENT_NOT_PROVIDED = 'GENERIC.ARGUMENT_NOT_PROVIDED';
|
||||
export const NOT_FOUND = 'GENERIC.NOT_FOUND';
|
||||
export const CONFLICT = 'GENERIC.CONFLICT';
|
||||
export const INTERNAL_SERVER_ERROR = 'GENERIC.INTERNAL_SERVER_ERROR';
|
||||
|
||||
82
src/libs/exceptions/exceptions.ts
Normal file
82
src/libs/exceptions/exceptions.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
ARGUMENT_INVALID,
|
||||
ARGUMENT_NOT_PROVIDED,
|
||||
ARGUMENT_OUT_OF_RANGE,
|
||||
CONFLICT,
|
||||
INTERNAL_SERVER_ERROR,
|
||||
NOT_FOUND,
|
||||
} from '.';
|
||||
import { ExceptionBase } from './exception.base';
|
||||
|
||||
/**
|
||||
* Used to indicate that an incorrect argument was provided to a method/function/class constructor
|
||||
*
|
||||
* @class ArgumentInvalidException
|
||||
* @extends {ExceptionBase}
|
||||
*/
|
||||
export class ArgumentInvalidException extends ExceptionBase {
|
||||
readonly code = ARGUMENT_INVALID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to indicate that an argument was not provided (is empty object/array, null of undefined).
|
||||
*
|
||||
* @class ArgumentNotProvidedException
|
||||
* @extends {ExceptionBase}
|
||||
*/
|
||||
export class ArgumentNotProvidedException extends ExceptionBase {
|
||||
readonly code = ARGUMENT_NOT_PROVIDED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to indicate that an argument is out of allowed range
|
||||
* (for example: incorrect string/array length, number not in allowed min/max range etc)
|
||||
*
|
||||
* @class ArgumentOutOfRangeException
|
||||
* @extends {ExceptionBase}
|
||||
*/
|
||||
export class ArgumentOutOfRangeException extends ExceptionBase {
|
||||
readonly code = ARGUMENT_OUT_OF_RANGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to indicate conflicting entities (usually in the database)
|
||||
*
|
||||
* @class ConflictException
|
||||
* @extends {ExceptionBase}
|
||||
*/
|
||||
export class ConflictException extends ExceptionBase {
|
||||
readonly code = CONFLICT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to indicate that entity is not found
|
||||
*
|
||||
* @class NotFoundException
|
||||
* @extends {ExceptionBase}
|
||||
*/
|
||||
export class NotFoundException extends ExceptionBase {
|
||||
static readonly message = 'Not found';
|
||||
|
||||
constructor(message = NotFoundException.message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
readonly code = NOT_FOUND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to indicate that entity is not found
|
||||
*
|
||||
* @class NotFoundException
|
||||
* @extends {ExceptionBase}
|
||||
*/
|
||||
export class InternalServerErrorException extends ExceptionBase {
|
||||
static readonly message = 'Internal server error';
|
||||
|
||||
constructor(message = InternalServerErrorException.message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
readonly code = INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
export * from './exception.base';
|
||||
export * from './argument-out-of-range.exception';
|
||||
export * from './conflict.exception';
|
||||
export * from './argument-invalid.exception';
|
||||
export * from './exception.codes';
|
||||
export * from './not-found.exception';
|
||||
export * from './argument-not-provided.exception';
|
||||
export * from './exceptions';
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ExceptionBase } from './exception.base';
|
||||
import { ExceptionCodes } from './exception.codes';
|
||||
|
||||
export class NotFoundException extends ExceptionBase {
|
||||
constructor(message = 'Not found') {
|
||||
super(message);
|
||||
}
|
||||
|
||||
readonly code = ExceptionCodes.notFound;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export class Guard {
|
||||
if (value.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (value.every(item => Guard.isEmpty(item))) {
|
||||
if (value.every((item) => Guard.isEmpty(item))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
export interface Logger {
|
||||
export interface LoggerPort {
|
||||
log(message: string, ...meta: unknown[]): void;
|
||||
error(message: string, trace?: unknown, ...meta: unknown[]): void;
|
||||
warn(message: string, ...meta: unknown[]): void;
|
||||
debug(message: string, ...meta: unknown[]): void;
|
||||
setContext(context: string): void;
|
||||
}
|
||||
@@ -7,3 +7,4 @@ export * from './deep-partial.type';
|
||||
export * from './non-function-properties.type';
|
||||
export * from './object-literal.type';
|
||||
export * from './require-one.type';
|
||||
export * from './mutable.type';
|
||||
|
||||
13
src/libs/types/mutable.type.ts
Normal file
13
src/libs/types/mutable.type.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Makes all properties of the type mutable
|
||||
* (removes readonly flag)
|
||||
*/
|
||||
export type Mutable<T> = {
|
||||
-readonly [key in keyof T]: T[key];
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes all properties of the type mutable recursively
|
||||
* (removes readonly flag, including in nested objects)
|
||||
*/
|
||||
export type DeepMutable<T> = { -readonly [P in keyof T]: DeepMutable<T[P]> };
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types */
|
||||
import { Entity } from '../base-classes/entity.base';
|
||||
import { ValueObject } from '../base-classes/value-object.base';
|
||||
import { Entity } from '../ddd/entity.base';
|
||||
import { ValueObject } from '../ddd/value-object.base';
|
||||
|
||||
function isEntity(obj: unknown): obj is Entity<unknown> {
|
||||
/**
|
||||
@@ -36,7 +36,7 @@ export function convertPropsToObject(props: any): any {
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const prop in propsCopy) {
|
||||
if (Array.isArray(propsCopy[prop])) {
|
||||
propsCopy[prop] = (propsCopy[prop] as Array<unknown>).map(item => {
|
||||
propsCopy[prop] = (propsCopy[prop] as Array<unknown>).map((item) => {
|
||||
return convertToPlainObject(item);
|
||||
});
|
||||
}
|
||||
9
src/libs/utils/dotenv.ts
Normal file
9
src/libs/utils/dotenv.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { config } from 'dotenv';
|
||||
import * as path from 'path';
|
||||
|
||||
// Initializing dotenv
|
||||
const envPath: string = path.resolve(
|
||||
__dirname,
|
||||
process.env.NODE_ENV === 'test' ? '../../../.env.test' : '../../../../.env',
|
||||
);
|
||||
config({ path: envPath });
|
||||
@@ -1,14 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
|
||||
/**
|
||||
* Remove undefined properties from an object
|
||||
*/
|
||||
export function removeUndefinedProps(item: any): any {
|
||||
// TODO: make recursive for nested objects
|
||||
const filtered: any = {};
|
||||
for (const key of Object.keys(item)) {
|
||||
if (item[key]) filtered[key] = item[key];
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
11
src/main.ts
11
src/main.ts
@@ -1,10 +1,9 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { ExceptionInterceptor } from './infrastructure/interceptors/exception.interceptor';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
const options = new DocumentBuilder().build();
|
||||
@@ -12,9 +11,7 @@ async function bootstrap(): Promise<void> {
|
||||
const document = SwaggerModule.createDocument(app, options);
|
||||
SwaggerModule.setup('docs', app, document);
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe());
|
||||
|
||||
app.useGlobalInterceptors(new ExceptionInterceptor());
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
|
||||
app.enableShutdownHooks();
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createUserCliLoggerSymbol } from '@modules/user/user.providers';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { Command, Console } from 'nestjs-console';
|
||||
import { Logger } from '@libs/ddd/domain/ports/logger.port';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import { CreateUserCommand } from './create-user.command';
|
||||
import { LoggerPort } from '@libs/ports/logger.port';
|
||||
|
||||
// Allows creating a user using CLI (Command Line Interface)
|
||||
@Console({
|
||||
@@ -13,8 +12,8 @@ import { CreateUserCommand } from './create-user.command';
|
||||
export class CreateUserCliController {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
@Inject(createUserCliLoggerSymbol)
|
||||
private readonly logger: Logger,
|
||||
@Inject(Logger)
|
||||
private readonly logger: LoggerPort,
|
||||
) {}
|
||||
|
||||
@Command({
|
||||
@@ -34,8 +33,8 @@ export class CreateUserCliController {
|
||||
street,
|
||||
});
|
||||
|
||||
const id = await this.commandBus.execute(command);
|
||||
const result = await this.commandBus.execute(command);
|
||||
|
||||
this.logger.log('User created:', id.unwrap().value);
|
||||
this.logger.log('User created:', result.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import {
|
||||
Command,
|
||||
CommandProps,
|
||||
} from '@src/libs/ddd/domain/base-classes/command.base';
|
||||
import { Command, CommandProps } from '@libs/ddd';
|
||||
|
||||
export class CreateUserCommand extends Command {
|
||||
constructor(props: CommandProps<CreateUserCommand>) {
|
||||
super(props);
|
||||
this.email = props.email;
|
||||
this.country = props.country;
|
||||
this.postalCode = props.postalCode;
|
||||
this.street = props.street;
|
||||
}
|
||||
|
||||
readonly email: string;
|
||||
|
||||
readonly country: string;
|
||||
@@ -19,4 +8,12 @@ export class CreateUserCommand extends Command {
|
||||
readonly postalCode: string;
|
||||
|
||||
readonly street: string;
|
||||
|
||||
constructor(props: CommandProps<CreateUserCommand>) {
|
||||
super(props);
|
||||
this.email = props.email;
|
||||
this.country = props.country;
|
||||
this.postalCode = props.postalCode;
|
||||
this.street = props.street;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { Body, Controller, HttpStatus, Post } from '@nestjs/common';
|
||||
import { IdResponse } from '@libs/ddd/interface-adapters/dtos/id.response.dto';
|
||||
import {
|
||||
Body,
|
||||
ConflictException as ConflictHttpException,
|
||||
Controller,
|
||||
HttpStatus,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { routesV1 } from '@config/app.routes';
|
||||
import { ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { CommandBus } from '@nestjs/cqrs';
|
||||
import { ID } from '@src/libs/ddd/domain/value-objects/id.value-object';
|
||||
import { ConflictException } from '@src/libs/exceptions';
|
||||
import { match, Result } from 'oxide.ts/dist';
|
||||
import { match, Result } from 'oxide.ts';
|
||||
import { CreateUserCommand } from './create-user.command';
|
||||
import { UserAlreadyExistsError } from '../../errors/user.errors';
|
||||
import { CreateUserRequest } from './create-user.request.dto';
|
||||
import { CreateUserRequestDto } from './create-user.request.dto';
|
||||
import { UserAlreadyExistsError } from '@modules/user/domain/user.errors';
|
||||
import { IdResponse } from '@libs/api/id.response.dto';
|
||||
import { AggregateID } from '@libs/ddd';
|
||||
import { ApiErrorResponse } from '@src/libs/api/api-error.response';
|
||||
|
||||
@Controller(routesV1.version)
|
||||
export class CreateUserHttpController {
|
||||
constructor(private readonly commandBus: CommandBus) {}
|
||||
|
||||
@Post(routesV1.user.root)
|
||||
@ApiOperation({ summary: 'Create a user' })
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
@@ -23,26 +28,27 @@ export class CreateUserHttpController {
|
||||
@ApiResponse({
|
||||
status: HttpStatus.CONFLICT,
|
||||
description: UserAlreadyExistsError.message,
|
||||
type: ApiErrorResponse,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.BAD_REQUEST,
|
||||
type: ApiErrorResponse,
|
||||
})
|
||||
async create(@Body() body: CreateUserRequest): Promise<IdResponse> {
|
||||
@Post(routesV1.user.root)
|
||||
async create(@Body() body: CreateUserRequestDto): Promise<IdResponse> {
|
||||
const command = new CreateUserCommand(body);
|
||||
|
||||
const result: Result<
|
||||
ID,
|
||||
UserAlreadyExistsError
|
||||
> = await this.commandBus.execute(command);
|
||||
const result: Result<AggregateID, UserAlreadyExistsError> =
|
||||
await this.commandBus.execute(command);
|
||||
|
||||
// Deciding what to do with a Result (similar to Rust matching)
|
||||
// if Ok we return a response with an id
|
||||
// if Error decide what to do with it depending on its type
|
||||
return match(result, {
|
||||
Ok: id => new IdResponse(id.value),
|
||||
Err: error => {
|
||||
Ok: (id: string) => new IdResponse(id),
|
||||
Err: (error: Error) => {
|
||||
if (error instanceof UserAlreadyExistsError)
|
||||
throw new ConflictException(error.message);
|
||||
throw new ConflictHttpException(error.message);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user