├── .dockerignore ├── .editorconfig ├── .env.example ├── .env.test ├── .eslintignore ├── .eslintrc ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── labels.yml ├── pr-scope-labeler.yml ├── settings.yml └── workflows │ ├── assign-me.yml │ ├── conventional-label.yml │ ├── dependabot-auto-merge.yml │ ├── dependency-review.yml │ ├── docker-size.yml │ ├── greetings.yml │ ├── lint-dockerfile.yml │ ├── lint-dotenv.yml │ ├── lint-github-action.yml │ ├── lint-pr-title.yml │ ├── lint-yaml.yml │ ├── node.yml │ ├── pr-scope-label.yml │ ├── pr-size-labeler.yml │ ├── stale-issues-and-prs.yml │ ├── sync-labels.yml │ └── todo-to-issue.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg ├── pre-commit └── pre-push ├── .npmignore ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .swcrc ├── .yamllint.yml ├── Dockerfile ├── LICENSE.md ├── README.md ├── commitlint.config.ts ├── create-vitest-test-config.ts ├── docker-compose.yml ├── docs ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md └── SECURITY.md ├── images └── nestjs.png ├── lint-staged.config.js ├── nest-cli.json ├── nyc.config.js ├── package-lock.json ├── package.json ├── prettier.config.js ├── scripts └── calculate-global-test-coverage.ts ├── src ├── app │ ├── app.module.ts │ └── http-api │ │ ├── health │ │ ├── health.controller.ts │ │ └── health.module.ts │ │ ├── http-api.module.ts │ │ ├── response-normalizer │ │ ├── error-response-normalizer.filter.ts │ │ ├── response-normalizer.module.ts │ │ └── success-response-normalizer.interceptor.ts │ │ └── routes │ │ └── route.constants.ts ├── contexts │ ├── payments │ │ ├── application │ │ │ ├── create-payment-use-case │ │ │ │ ├── create-payment.dto.ts │ │ │ │ └── create-payment.use-case.ts │ │ │ └── find-payment-by-id-use-case │ │ │ │ ├── find-payment-by-id.dto.ts │ │ │ │ └── find-payment-by-id.use-case.ts │ │ ├── domain │ │ │ ├── payment-not-found.exception.ts │ │ │ ├── payment.entity.ts │ │ │ └── payment.repository.ts │ │ └── infrastructure │ │ │ ├── http-api │ │ │ ├── route.constants.ts │ │ │ └── v1 │ │ │ │ ├── create-payment │ │ │ │ ├── create-payment.controller.ts │ │ │ │ └── create-payment.http-dto.ts │ │ │ │ └── find-payment-by-id │ │ │ │ ├── find-payment-by-id.controller.ts │ │ │ │ └── find-payment-by-id.http-dto.ts │ │ │ ├── payment.module.ts │ │ │ └── repositories │ │ │ └── in-memory.payment-repository.ts │ └── shared │ │ ├── dependency-injection │ │ └── injectable.ts │ │ └── logger │ │ ├── domain │ │ ├── index.ts │ │ └── logger.ts │ │ └── infrastructure │ │ ├── logger.interceptor.ts │ │ ├── logger.module.ts │ │ ├── nestjs.logger-service.ts │ │ └── pino.logger.ts └── main.ts ├── tests ├── e2e │ └── .keep ├── unit │ └── .keep └── utils │ └── mock.ts ├── tsconfig.json ├── tsconfig.prod.json ├── vitest.config.e2e.ts ├── vitest.config.ts └── vitest.config.unit.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !/package.json 3 | !/package-lock.json 4 | !/tsconfig.prod.json 5 | !/tsconfig.json 6 | !/.swcrc 7 | !/nest-cli.json 8 | !/src 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | LOGGER_LEVEL=log 2 | PORT=3000 3 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | coverage/ 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "project": "tsconfig.json", 6 | "sourceType": "module", 7 | "ecmaVersion": 2022 8 | }, 9 | "plugins": ["@typescript-eslint", "simple-import-sort"], 10 | "extends": [ 11 | "eslint:recommended", 12 | "plugin:@typescript-eslint/recommended", 13 | "plugin:@typescript-eslint/recommended-type-checked", 14 | "plugin:prettier/recommended", 15 | "plugin:unicorn/recommended", 16 | "plugin:node/recommended" 17 | ], 18 | "rules": { 19 | "simple-import-sort/imports": "error", 20 | "simple-import-sort/exports": "error", 21 | "unicorn/prefer-module": "off", 22 | "unicorn/prefer-top-level-await": "off", 23 | "unicorn/prevent-abbreviations": "off", 24 | "no-console": "warn", 25 | "node/no-missing-import": "off", 26 | "node/no-unsupported-features/es-syntax": [ 27 | "error", 28 | { "ignores": ["modules"] } 29 | ], 30 | "node/no-unpublished-import": "off", 31 | "no-process-exit": "off", 32 | "unicorn/no-null": "off" 33 | }, 34 | "overrides": [ 35 | { 36 | "files": ["*.ts"], 37 | "rules": { 38 | "simple-import-sort/imports": [ 39 | "error", 40 | { 41 | "groups": [ 42 | [ 43 | "^(assert|buffer|child_process|cluster|console|constants|crypto|dgram|dns|domain|events|fs|http|https|module|net|os|path|punycode|querystring|readline|repl|stream|string_decoder|sys|timers|tls|tty|url|util|vm|zlib|freelist|v8|process|async_hooks|http2|perf_hooks)(/.*|$)" 44 | ], 45 | ["^node:.*\\u0000$", "^@?\\w.*\\u0000$", "^[^.].*\\u0000$", "^\\..*\\u0000$"], 46 | ["^\\u0000"], 47 | ["^node:"], 48 | ["^@?\\w"], 49 | ["^@/src(/.*|$)"], 50 | ["^@/shared(/.*|$)"], 51 | ["^@/http-api(/.*|$)"], 52 | ["^@/contexts(/.*|$)"], 53 | ["^@/users(/.*|$)"], 54 | ["^@/tests(/.*|$)"], 55 | ["^"], 56 | ["^\\."] 57 | ] 58 | } 59 | ] 60 | } 61 | }, 62 | { 63 | "files": ["scripts/**"], 64 | "rules": { 65 | "no-console": "off" 66 | } 67 | }, 68 | { 69 | "files": ["tests/**"], 70 | "plugins": ["vitest"], 71 | "extends": ["plugin:vitest/recommended"], 72 | "rules": { 73 | "@typescript-eslint/unbound-method": "off", 74 | "vitest/expect-expect": "off", 75 | "vitest/no-standalone-expect": "off" 76 | } 77 | } 78 | ], 79 | "env": { 80 | "node": true 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @AlbertHernandez 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "🐞 Bug" 6 | assignees: "" 7 | --- 8 | 9 | # Prerequisites 10 | 11 | - [ ] I checked to make sure that this issue has not already been filed 12 | 13 | **Describe the bug** 14 | 15 | A clear and concise description of what the bug is. Include what is current behavior and what are you expecting. Add screenshots if needed and error details in JSON format so it can be easy to copy and paste. 16 | 17 | **To Reproduce** 18 | 19 | 1. Go to '...' 20 | 2. Click on '....' 21 | 3. Scroll down to '....' 22 | 4. See error 23 | 24 | **Context** 25 | 26 | Node Versions: 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "👀 Feature Requested" 6 | assignees: "" 7 | --- 8 | 9 | # Prerequisites 10 | 11 | - [ ] I checked the documentation and found no answer 12 | 13 | **Is your feature request related to a problem? Please describe.** 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 15 | 16 | **Describe the solution you'd like** 17 | A clear and concise description of what you want to happen. 18 | 19 | **Describe alternatives you've considered** 20 | A clear and concise description of any alternative solutions or features you've considered. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Proposed changes 2 | 3 | Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. 4 | 5 | ## Checklist 6 | 7 | _Put an `x` in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code._ 8 | 9 | - [ ] I have added tests that prove my fix is effective or that my feature works 10 | - [ ] I have added necessary documentation (if appropriate) 11 | - [ ] Any dependent changes have been merged and published in downstream modules 12 | 13 | ## Further comments 14 | 15 | If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... 16 | -------------------------------------------------------------------------------- /.github/labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: 👀 Feature Requested 3 | description: Request for a feature 4 | color: 07D90A 5 | - name: ignore-for-release 6 | description: Ignore pull request for a new release 7 | color: 9C28FC 8 | - name: todo 9 | description: Action we need to perform at some moment 10 | color: 82FC28 11 | - name: 💻 Source 12 | description: Indicates the scope is related to the own service logic 13 | color: FDC720 14 | - name: 🧪 Tests 15 | description: Indicates the scope is related to the tests 16 | color: 088E26 17 | - name: ⚙️ Configuration 18 | description: Indicates the scope is related to the configuration 19 | color: BDBDBD 20 | - name: 🐳 Build 21 | description: Indicates the change is related to the build 22 | color: 0FD4DA 23 | - name: 🚀 CI/CD 24 | description: Indicates the change is related to CI/CD workflows 25 | color: FF4D4D 26 | - name: 🏠 Github Configuration 27 | description: Indicates the change is related to github settings 28 | color: 555555 29 | - name: 🚀 Feature 30 | description: Feature added in the PR 31 | color: F10505 32 | - name: 🐞 Bug 33 | description: Bug identified 34 | color: F4D03F 35 | - name: 🕵🏻 Fix 36 | description: Fix applied in the PR 37 | color: F4D03F 38 | - name: ⚠️ Breaking Change 39 | description: Breaking change in the PR 40 | color: F1F800 41 | - name: 📦 Dependencies 42 | description: Pull requests that update a dependency file 43 | color: 95A5A6 44 | - name: 📝 Documentation 45 | description: Improvements or additions to documentation 46 | color: 228AFF 47 | - name: 🤦‍ Duplicate 48 | description: This issue or pull request already exists 49 | color: 17202A 50 | - name: 🤩 size/xs 51 | description: Pull request size XS 52 | color: 27AE60 53 | - name: 🥳 size/s 54 | description: Pull request size S 55 | color: 2ECC71 56 | - name: 😎 size/m 57 | description: Pull request size M 58 | color: F1C40F 59 | - name: 😖 size/l 60 | description: Pull request size L 61 | color: F39C12 62 | - name: 🤯 size/xl 63 | description: Pull request size XL 64 | color: E67E22 65 | -------------------------------------------------------------------------------- /.github/pr-scope-labeler.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 💻 Source: 3 | - changed-files: 4 | - any-glob-to-any-file: 5 | - src/** 6 | 7 | 🧪 Tests: 8 | - changed-files: 9 | - any-glob-to-any-file: 10 | - tests/** 11 | - .env.test 12 | - scripts/calculate-global-test-coverage.ts 13 | - vitest.config.**.ts 14 | - create-vitest-test-config.ts 15 | - nyc.config.js 16 | 17 | 📝 Documentation: 18 | - changed-files: 19 | - any-glob-to-any-file: 20 | - docs/** 21 | - README.md 22 | - images/** 23 | 24 | 🐳 Build: 25 | - changed-files: 26 | - any-glob-to-any-file: 27 | - .dockerignore 28 | - Dockerfile 29 | - docker-compose.yml 30 | - .nvmrc 31 | - .swcrc 32 | - tsconfig.json 33 | - tsconfig.prod.json 34 | 35 | ⚙️ Configuration: 36 | - changed-files: 37 | - any-glob-to-any-file: 38 | - .dockerignore 39 | - .editorconfig 40 | - .env.example 41 | - .eslintignore 42 | - .eslintrc 43 | - .gitignore 44 | - .npmignore 45 | - .npmrc 46 | - .nvmrc 47 | - .prettierignore 48 | - .swcrc 49 | - .yamllint.yml 50 | - .commitlint.config.js 51 | - vitest.config.**.ts 52 | - create-vitest-test-config.ts 53 | - commitlint.config.ts 54 | - nest-cli.json 55 | - nyc.config.js 56 | - prettier.config.js 57 | - tsconfig.json 58 | - tsconfig.prod.json 59 | 60 | 📦 Dependencies: 61 | - changed-files: 62 | - any-glob-to-any-file: 63 | - package.json 64 | - package-lock.json 65 | 66 | 🚀 CI/CD: 67 | - changed-files: 68 | - any-glob-to-any-file: 69 | - .github/workflows/** 70 | - .github/dependabot.yml 71 | - .github/pr-scope-labeler.yml 72 | - .husky/** 73 | 74 | 🏠 Github Configuration: 75 | - changed-files: 76 | - any-glob-to-any-file: 77 | - .github/ISSUE_TEMPLATE/** 78 | - .github/CODEOWNERS 79 | - .github/labels.yml 80 | - .github/PULL_REQUEST_TEMPLATE.md 81 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | --- 2 | repository: 3 | name: nestjs-hexagonal-architecture-example 4 | description: Example of how to create a NestJS service using hexagonal architecture 5 | homepage: github.com/AlbertHernandez/nestjs-hexagonal-architecture-example 6 | topics: nodejs, typescript, nestjs, hexagonal architecture 7 | has_wiki: false 8 | private: false 9 | has_issues: true 10 | has_projects: false 11 | default_branch: main 12 | allow_squash_merge: true 13 | allow_merge_commit: false 14 | allow_rebase_merge: false 15 | delete_branch_on_merge: true 16 | enable_automated_security_fixes: true 17 | enable_vulnerability_alerts: true 18 | branches: 19 | - name: main 20 | protection: 21 | required_pull_request_reviews: 22 | required_approving_review_count: 1 23 | dismiss_stale_reviews: true 24 | require_code_owner_reviews: true 25 | required_status_checks: 26 | strict: true 27 | contexts: [] 28 | enforce_admins: false 29 | required_linear_history: true 30 | restrictions: null 31 | -------------------------------------------------------------------------------- /.github/workflows/assign-me.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Assign me' 3 | 4 | on: 5 | pull_request: 6 | 7 | jobs: 8 | set_assignee: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/github-script@v7 12 | if: github.actor != 'dependabot[bot]' 13 | with: 14 | script: | 15 | github.rest.issues.addAssignees({ 16 | owner: context.repo.owner, 17 | repo: context.repo.repo, 18 | issue_number: context.issue.number, 19 | assignees: [context.actor], 20 | }) 21 | -------------------------------------------------------------------------------- /.github/workflows/conventional-label.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Conventional release labels' 3 | 4 | on: 5 | pull_request_target: 6 | types: [opened, edited] 7 | 8 | jobs: 9 | label: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: bcoe/conventional-release-labels@v1 13 | with: 14 | type_labels: '{"feat": "🚀 Feature", "fix": "🕵🏻 Fix", "breaking": "⚠️ Breaking Change"}' 15 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Dependabot auto merge' 3 | 4 | on: 5 | pull_request: 6 | 7 | jobs: 8 | auto-merge: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: ahmadnassri/action-dependabot-auto-merge@v2 13 | with: 14 | target: minor 15 | github-token: ${{ secrets.DEPENDABOT_AUTO_MERGE_GITHUB_TOKEN }} 16 | command: squash and merge 17 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Dependency Review' 3 | 4 | on: 5 | pull_request: 6 | 7 | permissions: 8 | contents: read 9 | pull-requests: write 10 | 11 | jobs: 12 | dependency-review: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: 'Checkout Repository' 16 | uses: actions/checkout@v4 17 | - name: 'Dependency Review' 18 | uses: actions/dependency-review-action@v4 19 | with: 20 | comment-summary-in-pr: always 21 | -------------------------------------------------------------------------------- /.github/workflows/docker-size.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Docker size' 3 | 4 | on: 5 | pull_request: 6 | 7 | permissions: 8 | pull-requests: write 9 | 10 | jobs: 11 | calculate-base: 12 | runs-on: ubuntu-latest 13 | outputs: 14 | image_size: ${{ steps.docker-base.outputs.image_size }} 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | ref: ${{ github.base_ref }} 19 | - name: Get commit short hash 20 | id: commit 21 | run: | 22 | short=$(git rev-parse --short HEAD) 23 | echo "short=$short" >> "$GITHUB_OUTPUT" 24 | - name: 📦 Cache docker image for commit ${{ steps.commit.outputs.short }} 25 | uses: actions/cache@v4 26 | with: 27 | path: base-docker-image.txt 28 | key: base-docker-image-os-${{ runner.os }}-commit-${{ steps.commit.outputs.short }} 29 | - name: 🐳 Calculate docker image size in ${{ github.base_ref }} 30 | id: docker-base 31 | run: | 32 | if [ -f base-docker-image.txt ]; then 33 | echo "Getting docker image from cache" 34 | image_size=$( base-docker-image.txt 41 | echo "image_size=$image_size" >> "$GITHUB_OUTPUT" 42 | calculate-head: 43 | runs-on: ubuntu-latest 44 | outputs: 45 | image_size: ${{ steps.docker-head.outputs.image_size }} 46 | steps: 47 | - uses: actions/checkout@v4 48 | with: 49 | ref: ${{ github.head_ref }} 50 | - name: 🐳 Calculate docker image size in ${{ github.head_ref }} 51 | id: docker-head 52 | run: | 53 | docker build . -t service 54 | image_size=$(docker images service | awk 'NR==2 {print $NF}') 55 | echo "image_size=$image_size" >> "$GITHUB_OUTPUT" 56 | write-comment: 57 | runs-on: ubuntu-latest 58 | needs: [calculate-base, calculate-head] 59 | steps: 60 | - uses: marocchino/sticky-pull-request-comment@v2 61 | env: 62 | BASE_DOCKER_IMAGE_SIZE: ${{needs.calculate-base.outputs.image_size}} 63 | HEAD_DOCKER_IMAGE_SIZE: ${{needs.calculate-head.outputs.image_size}} 64 | with: 65 | header: 66 | message: | 67 | ## 🐳 Docker Metrics 🐳 68 | 69 | * Size of the Docker Image in the base (${{ github.base_ref }}): **${{ env.BASE_DOCKER_IMAGE_SIZE }}** 70 | * Size of the Docker Image in this branch (${{ github.head_ref }}): **${{ env.HEAD_DOCKER_IMAGE_SIZE }}** 71 | -------------------------------------------------------------------------------- /.github/workflows/greetings.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Greetings' 3 | 4 | on: 5 | pull_request: 6 | issues: 7 | 8 | jobs: 9 | greeting: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | issues: write 13 | pull-requests: write 14 | steps: 15 | - uses: actions/first-interaction@v1 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | issue-message: | 19 | Hey @${{github.actor}} 👋! 20 | 21 | Thank you for opening your first issue here! ♥️ 22 | If you are reporting a bug 🐞, please make sure to include steps on how to reproduce it. 23 | 24 | We will take it a look as soon as we can 💪 25 | pr-message: | 26 | Hey @${{github.actor}} 👋! 27 | 28 | Thank you for being here and helping this project to grow 🚀 29 | We will review it as soon as we can :D 30 | 31 | Please check out our contributing guidelines in the meantime 📃 32 | -------------------------------------------------------------------------------- /.github/workflows/lint-dockerfile.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Lint dockerfile' 3 | 4 | on: 5 | push: 6 | 7 | jobs: 8 | build: 9 | name: Lint dockerfile 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: hadolint/hadolint-action@v3.1.0 14 | id: hadolint 15 | with: 16 | dockerfile: Dockerfile 17 | - name: Build dockerfile 18 | run: docker build . -t service 19 | -------------------------------------------------------------------------------- /.github/workflows/lint-dotenv.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Lint dotenv' 3 | 4 | on: 5 | push: 6 | 7 | jobs: 8 | build: 9 | name: Lint dotenv 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Install dotenv 14 | run: curl -sSfL https://git.io/JLbXn | sh -s -- -b usr/local/bin v3.3.0 15 | - name: Run dotenv 16 | run: usr/local/bin/dotenv-linter 17 | -------------------------------------------------------------------------------- /.github/workflows/lint-github-action.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Lint GitHub Actions workflows' 3 | 4 | on: 5 | push: 6 | 7 | jobs: 8 | actionlint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Download actionlint 13 | id: get_actionlint 14 | run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) 1.6.26 15 | shell: bash 16 | - name: Check workflow files 17 | run: ${{ steps.get_actionlint.outputs.executable }} -color 18 | shell: bash 19 | -------------------------------------------------------------------------------- /.github/workflows/lint-pr-title.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Lint PR Title' 3 | 4 | on: 5 | pull_request: 6 | types: 7 | - opened 8 | - edited 9 | - synchronize 10 | - labeled 11 | - unlabeled 12 | 13 | permissions: 14 | pull-requests: write 15 | 16 | jobs: 17 | main: 18 | name: Validate PR title 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: amannn/action-semantic-pull-request@v5 22 | id: lint_pr_title 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | with: 26 | ignoreLabels: | 27 | bot 28 | autorelease: pending 29 | - uses: marocchino/sticky-pull-request-comment@v2 30 | if: always() && (steps.lint_pr_title.outputs.error_message != null) 31 | with: 32 | header: pr-title-lint-error 33 | message: | 34 | Hey mate 👋. Thank you for opening this Pull Request 🤘. It is really awesome to see this contribution 🚀 35 | 36 | 🔎 When working with this project we are requesting to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted 🥶. 37 | 38 | 👇 Bellow you can find details about what failed: 39 | 40 | ``` 41 | ${{ steps.lint_pr_title.outputs.error_message }} 42 | ``` 43 | 44 | - if: ${{ steps.lint_pr_title.outputs.error_message == null }} 45 | uses: marocchino/sticky-pull-request-comment@v2 46 | with: 47 | header: pr-title-lint-error 48 | delete: true 49 | -------------------------------------------------------------------------------- /.github/workflows/lint-yaml.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Lint yaml' 3 | 4 | on: 5 | push: 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Install yamllint 13 | run: pip install yamllint 14 | - name: Lint YAML files 15 | run: yamllint . 16 | -------------------------------------------------------------------------------- /.github/workflows/node.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Node' 3 | 4 | on: 5 | push: 6 | 7 | jobs: 8 | run: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 🛬 12 | uses: actions/checkout@v4 13 | - name: Cache Dependencies ⌛️ 14 | uses: actions/cache@v4 15 | id: cache-node-modules 16 | with: 17 | path: 'node_modules' 18 | key: ${{ runner.os }}-node_modules-${{ hashFiles('package*.json') }}-${{ hashFiles('.github/workflows/node.yml') }} 19 | - name: Setup Node ⚙️ 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version-file: '.nvmrc' 23 | cache: npm 24 | - name: Install dependencies 📥 25 | if: steps.cache-node-modules.outputs.cache-hit != 'true' 26 | run: | 27 | # Bellow npm install is a workaround for https://github.com/swc-project/swc/issues/5616#issuecomment-1651214641 28 | npm install --save-optional \ 29 | "@swc/core-linux-x64-gnu@1" \ 30 | "@swc/core-linux-x64-musl@1" 31 | - name: Build typescript 📦 32 | run: npm run build && find dist/main.js 33 | - name: Lint code 💅 34 | run: npm run lint 35 | - name: Run tests ✅ 36 | run: npm run test 37 | -------------------------------------------------------------------------------- /.github/workflows/pr-scope-label.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'PR Scope label' 3 | 4 | on: 5 | pull_request_target: 6 | 7 | jobs: 8 | labeler: 9 | permissions: 10 | contents: read 11 | pull-requests: write 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/labeler@v5 15 | with: 16 | configuration-path: .github/pr-scope-labeler.yml 17 | sync-labels: true 18 | -------------------------------------------------------------------------------- /.github/workflows/pr-size-labeler.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'PR Size Labeler' 3 | 4 | on: 5 | pull_request: 6 | 7 | jobs: 8 | labeler: 9 | runs-on: ubuntu-latest 10 | name: Label the PR size 11 | steps: 12 | - uses: codelytv/pr-size-labeler@v1 13 | with: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | xs_label: '🤩 size/xs' 16 | xs_max_size: '10' 17 | s_label: '🥳 size/s' 18 | s_max_size: '100' 19 | m_label: '😎 size/m' 20 | m_max_size: '500' 21 | l_label: '😖 size/l' 22 | l_max_size: '1000' 23 | xl_label: '🤯 size/xl' 24 | fail_if_xl: 'false' 25 | message_if_xl: > 26 | This PR exceeds the recommended size of 1000 lines. 27 | Please make sure you are NOT addressing multiple issues with one PR. 28 | Note this PR might be rejected due to its size. 29 | files_to_ignore: | 30 | "package-lock.json" 31 | "*.lock" 32 | "docs/*" 33 | -------------------------------------------------------------------------------- /.github/workflows/stale-issues-and-prs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Close stale issues and PRs' 3 | 4 | on: 5 | schedule: 6 | - cron: '30 1 * * *' 7 | 8 | jobs: 9 | stale: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/stale@v9 13 | with: 14 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 15 | stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.' 16 | days-before-issue-stale: 30 17 | days-before-pr-stale: 30 18 | days-before-issue-close: 5 19 | days-before-pr-close: 5 20 | -------------------------------------------------------------------------------- /.github/workflows/sync-labels.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Sync labels' 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: micnncim/action-label-syncer@v1 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/todo-to-issue.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Todo to issue' 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: "TODO to Issue" 15 | uses: "alstr/todo-to-issue-action@v4" 16 | with: 17 | ISSUE_TEMPLATE: | 18 | ## ✅ Codebase TODO ✅ 19 | 20 | ### **📝 Title**: {{ title }} 21 | 22 | ### **🔎 Details** 23 | 24 | {{ body }} 25 | {{ url }} 26 | {{ snippet }} 27 | AUTO_ASSIGN: true 28 | IGNORE: ".github/workflows/todo-to-issue.yml" 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .tmp 4 | .idea 5 | .env 6 | coverage/ 7 | .npm 8 | .vscode 9 | .nyc_output 10 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npm run build 2 | find dist/main.js 3 | npm run test 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .tmp 4 | .idea 5 | .env 6 | coverage/ 7 | .npm 8 | .vscode 9 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.9 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | coverage/ 3 | node_modules/ 4 | tsconfig.json 5 | tsconfig.prod.json 6 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "sourceMaps": true, 4 | "jsc": { 5 | "parser": { 6 | "syntax": "typescript", 7 | "decorators": true, 8 | "dynamicImport": true 9 | }, 10 | "transform": { 11 | "legacyDecorator": true, 12 | "decoratorMetadata": true 13 | }, 14 | "baseUrl": "./", 15 | "paths": { 16 | "@src/*": ["src/*"], 17 | "@shared/*": ["src/shared/*"], 18 | "@tests/*": ["tests/*"] 19 | }, 20 | "target": "es2022" 21 | }, 22 | "minify": false 23 | } 24 | -------------------------------------------------------------------------------- /.yamllint.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | extends: default 4 | 5 | ignore: | 6 | node_modules/ 7 | 8 | rules: 9 | line-length: 10 | ignore: | 11 | /.github/workflows/*.yaml 12 | /.github/workflows/*.yml 13 | /.github/settings.yml 14 | truthy: 15 | ignore: | 16 | /.github/workflows/*.yaml 17 | /.github/workflows/*.yml 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine3.18 AS base 2 | 3 | ENV DIR /app 4 | WORKDIR $DIR 5 | ARG NPM_TOKEN 6 | 7 | FROM base AS dev 8 | 9 | ENV NODE_ENV=development 10 | 11 | COPY package*.json . 12 | 13 | RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ".npmrc" && \ 14 | npm ci && \ 15 | rm -f .npmrc 16 | 17 | COPY tsconfig*.json . 18 | COPY .swcrc . 19 | COPY nest-cli.json . 20 | COPY src src 21 | 22 | EXPOSE $PORT 23 | CMD ["npm", "run", "dev"] 24 | 25 | FROM base AS build 26 | 27 | RUN apk update && apk add --no-cache dumb-init=1.2.5-r2 28 | 29 | COPY package*.json . 30 | # Bellow npm install is a workaround for https://github.com/swc-project/swc/issues/5616#issuecomment-1651214641 31 | RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ".npmrc" && \ 32 | npm install --save-optional \ 33 | "@swc/core-linux-x64-gnu@1" \ 34 | "@swc/core-linux-x64-musl@1" && \ 35 | rm -f .npmrc 36 | 37 | COPY tsconfig*.json . 38 | COPY .swcrc . 39 | COPY nest-cli.json . 40 | COPY src src 41 | 42 | RUN npm run build && \ 43 | npm prune --production 44 | 45 | FROM base AS production 46 | 47 | ENV NODE_ENV=production 48 | ENV USER=node 49 | 50 | COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init 51 | COPY --from=build $DIR/node_modules node_modules 52 | COPY --from=build $DIR/dist dist 53 | 54 | USER $USER 55 | EXPOSE $PORT 56 | CMD ["dumb-init", "node", "dist/main.js"] 57 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Albert Hernandez 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 |

NestJS Hexagonal Architecture Example

6 | 7 |

8 | Example of how to create a NestJS service using hexagonal architecture. 9 |

10 | 11 |

12 | nodejs 13 | node 14 | typescript 15 | npm 16 | fastify 17 | swc 18 | swc 19 | docker 20 |

21 | 22 | ## 👀 Motivation 23 | 24 | The main idea of this project is to show how we can create a NestJS service using the hexagonal architecture. Note that this repository is not a template for starting new projects so we will not keep it up to date in terms of dependencies, vulnerabilities or new practices, if you are looking for a new template we recommend you to check the [project templates](#-project-templates) section. 25 | 26 | ## 🤩 Project templates 27 | 28 | Are you thinking in start new projects in nestjs, other frameworks or create a super fancy library? I recommend you to check the following templates I have been working on: 29 | 30 | - [Template for new NestJS Services](https://github.com/AlbertHernandez/nestjs-service-template) 31 | - [Template for new Typescript Libraries](https://github.com/AlbertHernandez/typescript-library-template) 32 | - [Template for new Typescript Express Services](https://github.com/AlbertHernandez/express-typescript-service-template) 33 | - [Template for new GitHub Actions based on NodeJS](https://github.com/AlbertHernandez/github-action-nodejs-template) 34 | 35 | ## 🧑‍💻 Developing 36 | 37 | First, we will need to create our .env file, we can create a copy from the example one: 38 | 39 | ```bash 40 | cp .env.example .env 41 | ``` 42 | 43 | The project is fully dockerized 🐳, if we want to start the app in **development mode**, we just need to run: 44 | 45 | ```bash 46 | docker-compose up -d my-service-dev 47 | ``` 48 | 49 | This development mode with work with **hot-reload** and exposing a **debug port**, the `9229`, so later we can connect from our editor to it. 50 | 51 | Now, you should be able to start debugging configuring using your IDE. For example, if you are using vscode, you can create a `.vscode/launch.json` file with the following configuration: 52 | 53 | ```json 54 | { 55 | "version": "0.1.0", 56 | "configurations": [ 57 | { 58 | "type": "node", 59 | "request": "attach", 60 | "name": "Attach to docker", 61 | "restart": true, 62 | "port": 9229, 63 | "remoteRoot": "/app" 64 | } 65 | ] 66 | } 67 | ``` 68 | 69 | Also, if you want to run the **production mode**, you can run: 70 | 71 | ```bash 72 | docker-compose up -d my-service-production 73 | ``` 74 | 75 | This service is providing just a health endpoint which you can call to verify the service is working as expected: 76 | 77 | ```bash 78 | curl --request GET \ 79 | --url http://localhost:3000/health 80 | ``` 81 | 82 | If you want to stop developing, you can stop the service running: 83 | 84 | ```bash 85 | docker-compose down 86 | ``` 87 | 88 | ## ⚙️ Building 89 | 90 | ```bash 91 | npm run build 92 | ``` 93 | 94 | ## ✅ Testing 95 | 96 | The service provide different scripts for running the tests, to run all of them you can run: 97 | 98 | ```bash 99 | npm run test 100 | ``` 101 | 102 | If you are interested just in the unit tests, you can run: 103 | 104 | ```bash 105 | npm run test:unit 106 | ``` 107 | 108 | Or if you want e2e tests, you can execute: 109 | 110 | ```bash 111 | npm run test:e2e 112 | ``` 113 | 114 | ## 💅 Linting 115 | 116 | To run the linter you can execute: 117 | 118 | ```bash 119 | npm run lint 120 | ``` 121 | 122 | And for trying to fix lint issues automatically, you can run: 123 | 124 | ```bash 125 | npm run lint:fix 126 | ``` 127 | -------------------------------------------------------------------------------- /commitlint.config.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from "@commitlint/types"; 2 | 3 | const config: UserConfig = { 4 | extends: ["@commitlint/config-conventional"], 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /create-vitest-test-config.ts: -------------------------------------------------------------------------------- 1 | import { loadEnv } from "vite"; 2 | import { InlineConfig } from "vitest"; 3 | 4 | export const createVitestTestConfig = (testingType: string): InlineConfig => { 5 | return { 6 | root: "./", 7 | globals: true, 8 | isolate: false, 9 | passWithNoTests: true, 10 | include: [`tests/${testingType}/**/*.test.ts`], 11 | env: loadEnv("test", process.cwd(), ""), 12 | coverage: { 13 | provider: "istanbul", 14 | reporter: ["text", "json", "html"], 15 | reportsDirectory: `coverage/${testingType}`, 16 | include: ["src/**/*.ts"], 17 | }, 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | my-service-production: 5 | container_name: my-service-production 6 | build: 7 | target: production 8 | context: . 9 | args: 10 | - PORT=${PORT:-3000} 11 | ports: 12 | - "${PORT:-3000}:${PORT:-3000}" 13 | deploy: 14 | resources: 15 | limits: 16 | cpus: "1" 17 | memory: "512m" 18 | reservations: 19 | cpus: "0.25" 20 | memory: "256m" 21 | 22 | my-service-dev: 23 | container_name: my-service-dev 24 | restart: unless-stopped 25 | env_file: .env 26 | build: 27 | target: dev 28 | context: . 29 | args: 30 | - PORT=${PORT:-3000} 31 | ports: 32 | - "${PORT:-3000}:${PORT:-3000}" 33 | - "9229:9229" 34 | volumes: 35 | - .:/app 36 | - node_modules:/app/node_modules/ 37 | deploy: 38 | resources: 39 | limits: 40 | cpus: "1" 41 | memory: "512m" 42 | reservations: 43 | cpus: "0.25" 44 | memory: "256m" 45 | 46 | volumes: 47 | node_modules: 48 | -------------------------------------------------------------------------------- /docs/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## 👏 Contributing 2 | 3 | As a contributor, here are the guidelines you should follow: 4 | 5 | - [👔 Code of Conduct](CODE_OF_CONDUCT.md) 6 | - [⭐️ Steps](#-steps) 7 | - [💻️ Developing](../README.md#-developing) 8 | 9 | --- 10 | 11 | ## ⭐️ Steps 12 | 13 | 1. Use the issue tracker to make sure the feature request or bug has not been already reported 🔎. 14 | 2. Submit an issue describing your proposed change to the repo 💡. 15 | 3. The repo owner will respond to your issue as soon as we can 💪. 16 | 4. If your proposal change is accepted, fork the repo, develop and test your code changes 🤝. 17 | 5. Ensure that your code adheres to the existing style in the code 💅🏻. 18 | 6. Title your pull request following [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) styling 🪄. 19 | 20 | --- 21 | -------------------------------------------------------------------------------- /docs/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | Please report security issues to `alberthernandezdev@gmail.com`. 6 | -------------------------------------------------------------------------------- /images/nestjs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbertHernandez/nestjs-hexagonal-architecture-example/dbb23feceb117c22f3ea04caee5bc20884203ae8/images/nestjs.png -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | "**/*.ts?(x)": () => "tsc -p tsconfig.prod.json --noEmit", 3 | "*.{js,jsx,ts,tsx}": ["npm run lint", "vitest related --run"], 4 | "*.{md,json}": "prettier --write", 5 | }; 6 | 7 | module.exports = config; 8 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true, 7 | "builder": "swc", 8 | "typeCheck": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /nyc.config.js: -------------------------------------------------------------------------------- 1 | const MIN_COVERAGE = 80; 2 | 3 | const config = { 4 | all: true, 5 | "check-coverage": false, 6 | branches: MIN_COVERAGE, 7 | lines: MIN_COVERAGE, 8 | functions: MIN_COVERAGE, 9 | statements: MIN_COVERAGE, 10 | reporter: ["lcov", "json", "text"], 11 | }; 12 | 13 | module.exports = config; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-hexagonal-architecture-example", 3 | "version": "1.0.0", 4 | "description": "Example of how to create a NestJS service using hexagonal architecture", 5 | "author": "alberthernandezdev@gmail.com", 6 | "license": "MIT", 7 | "bugs": { 8 | "url": "https://github.com/AlbertHernandez/nestjs-hexagonal-architecture-example/issues" 9 | }, 10 | "homepage": "https://github.com/AlbertHernandez/nestjs-hexagonal-architecture-example#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/AlbertHernandez/nestjs-hexagonal-architecture-example.git" 14 | }, 15 | "keywords": [ 16 | "typescript", 17 | "nestjs", 18 | "hexagonal architecture" 19 | ], 20 | "engines": { 21 | "node": ">=18.x", 22 | "npm": ">=9.x" 23 | }, 24 | "main": "dist/main.js", 25 | "scripts": { 26 | "build": "npm run build:clean && nest build --path tsconfig.prod.json", 27 | "start": "node dist/main.js", 28 | "dev": "tsnd -r tsconfig-paths/register --inspect=0.0.0.0:9229 --respawn src/main.ts | pino-pretty --messageKey message", 29 | "test": "rimraf coverage .nyc_output && concurrently 'npm:test:unit' 'npm:test:e2e' && npm run calculate-global-test-coverage", 30 | "test:unit": "vitest run --coverage --config vitest.config.unit.ts", 31 | "test:e2e": "vitest run --coverage --config ./vitest.config.e2e.ts", 32 | "calculate-global-test-coverage": "ts-node scripts/calculate-global-test-coverage.ts", 33 | "prepare": "husky", 34 | "lint": "eslint --ignore-path .gitignore . --ext .js,.ts", 35 | "lint:fix": "npm run lint -- --fix", 36 | "build:clean": "rimraf dist; exit 0" 37 | }, 38 | "dependencies": { 39 | "@nestjs/cli": "^10.3.2", 40 | "@nestjs/common": "^10.3.4", 41 | "@nestjs/config": "^3.2.0", 42 | "@nestjs/core": "^10.3.4", 43 | "@nestjs/platform-fastify": "^10.3.4", 44 | "class-transformer": "^0.5.1", 45 | "class-validator": "^0.14.1", 46 | "fastify": "^4.26.2", 47 | "pino": "^8.19.0", 48 | "reflect-metadata": "^0.2.1", 49 | "rxjs": "^7.8.1", 50 | "uuid": "^9.0.1" 51 | }, 52 | "devDependencies": { 53 | "@commitlint/cli": "^19.2.1", 54 | "@commitlint/config-conventional": "^19.1.0", 55 | "@commitlint/types": "^19.0.3", 56 | "@nestjs/schematics": "^10.1.1", 57 | "@nestjs/testing": "^10.3.4", 58 | "@swc/cli": "^0.3.10", 59 | "@swc/core": "^1.4.8", 60 | "@types/fs-extra": "^11.0.4", 61 | "@types/node": "^20.11.30", 62 | "@types/supertest": "^6.0.2", 63 | "@types/uuid": "^9.0.8", 64 | "@typescript-eslint/eslint-plugin": "^7.3.1", 65 | "@typescript-eslint/parser": "^7.3.1", 66 | "@vitest/coverage-istanbul": "^1.3.1", 67 | "concurrently": "^8.2.2", 68 | "eslint": "^8.57.0", 69 | "eslint-config-prettier": "^9.0.0", 70 | "eslint-plugin-node": "^11.1.0", 71 | "eslint-plugin-prettier": "^5.1.3", 72 | "eslint-plugin-simple-import-sort": "^12.0.0", 73 | "eslint-plugin-unicorn": "^51.0.1", 74 | "eslint-plugin-vitest": "^0.3.26", 75 | "fs-extra": "^11.2.0", 76 | "husky": "^9.0.11", 77 | "lint-staged": "^15.2.2", 78 | "nock": "^13.5.4", 79 | "nyc": "^15.1.0", 80 | "pino-pretty": "^11.0.0", 81 | "prettier": "^3.2.5", 82 | "rimraf": "^5.0.5", 83 | "source-map-support": "^0.5.21", 84 | "supertest": "^6.3.4", 85 | "ts-loader": "^9.5.1", 86 | "ts-node": "^10.9.2", 87 | "ts-node-dev": "^2.0.0", 88 | "tsconfig-paths": "^4.2.0", 89 | "typescript": "^5.4.3", 90 | "unplugin-swc": "^1.4.4", 91 | "vite": "^5.2.2", 92 | "vitest": "^1.3.1", 93 | "vitest-mock-extended": "^1.3.1" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: true, 6 | singleQuote: false, 7 | trailingComma: "all", 8 | bracketSpacing: true, 9 | arrowParens: "avoid", 10 | }; 11 | -------------------------------------------------------------------------------- /scripts/calculate-global-test-coverage.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "node:child_process"; 2 | import path from "node:path"; 3 | 4 | import fs from "fs-extra"; 5 | 6 | const REPORTS_PATH = path.resolve(process.cwd(), ".nyc_output"); 7 | const COVERAGE_PATH = path.resolve(process.cwd(), "coverage"); 8 | 9 | fs.emptyDirSync(REPORTS_PATH); 10 | fs.copyFileSync( 11 | `${COVERAGE_PATH}/unit/coverage-final.json`, 12 | `${REPORTS_PATH}/unit-coverage.json`, 13 | ); 14 | fs.copyFileSync( 15 | `${COVERAGE_PATH}/e2e/coverage-final.json`, 16 | `${REPORTS_PATH}/e2e-coverage.json`, 17 | ); 18 | execSync(`nyc report --report-dir ${COVERAGE_PATH}/global`, { 19 | stdio: "inherit", 20 | }); 21 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | import { ConfigModule } from "@nestjs/config"; 3 | 4 | import { HttpApiModule } from "@/src/app/http-api/http-api.module"; 5 | 6 | import { LoggerModule } from "@/shared/logger/infrastructure/logger.module"; 7 | 8 | import { PaymentModule } from "@/contexts/payments/infrastructure/payment.module"; 9 | 10 | @Module({ 11 | imports: [ 12 | HttpApiModule, 13 | LoggerModule, 14 | ConfigModule.forRoot({ isGlobal: true, cache: true }), 15 | PaymentModule, 16 | ], 17 | }) 18 | export class AppModule {} 19 | -------------------------------------------------------------------------------- /src/app/http-api/health/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from "@nestjs/common"; 2 | 3 | @Controller("health") 4 | export class HealthController { 5 | @Get() 6 | run() { 7 | return { status: "ok" }; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/http-api/health/health.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | 3 | import { HealthController } from "./health.controller"; 4 | 5 | @Module({ 6 | controllers: [HealthController], 7 | }) 8 | export class HealthModule {} 9 | -------------------------------------------------------------------------------- /src/app/http-api/http-api.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | 3 | import { HealthModule } from "./health/health.module"; 4 | import { ResponseNormalizerModule } from "./response-normalizer/response-normalizer.module"; 5 | 6 | @Module({ 7 | imports: [HealthModule, ResponseNormalizerModule], 8 | }) 9 | export class HttpApiModule {} 10 | -------------------------------------------------------------------------------- /src/app/http-api/response-normalizer/error-response-normalizer.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | BadRequestException, 4 | Catch, 5 | ExceptionFilter, 6 | HttpException, 7 | InternalServerErrorException, 8 | } from "@nestjs/common"; 9 | import { FastifyReply } from "fastify"; 10 | 11 | @Catch() 12 | export class ErrorResponseNormalizerFilter implements ExceptionFilter { 13 | async catch(rawException: Error, host: ArgumentsHost) { 14 | const ctx = host.switchToHttp(); 15 | 16 | const response = ctx.getResponse(); 17 | 18 | const exception = 19 | rawException instanceof HttpException 20 | ? rawException 21 | : new InternalServerErrorException(); 22 | 23 | const status = exception.getStatus(); 24 | 25 | await response.status(status).send({ error: this.mapToError(exception) }); 26 | } 27 | 28 | private mapToError(error: HttpException) { 29 | return { 30 | message: error.message, 31 | status: error.getStatus(), 32 | reasons: this.getReasons(error), 33 | }; 34 | } 35 | 36 | private getReasons(error: HttpException): string[] | undefined { 37 | if (!(error instanceof BadRequestException)) { 38 | return; 39 | } 40 | 41 | const response = error.getResponse() as { message?: string[] }; 42 | return response?.message || []; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/http-api/response-normalizer/response-normalizer.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | 3 | import { ErrorResponseNormalizerFilter } from "./error-response-normalizer.filter"; 4 | import { SuccessResponseNormalizerInterceptor } from "./success-response-normalizer.interceptor"; 5 | 6 | @Module({ 7 | providers: [ 8 | SuccessResponseNormalizerInterceptor, 9 | ErrorResponseNormalizerFilter, 10 | ], 11 | exports: [ 12 | SuccessResponseNormalizerInterceptor, 13 | ErrorResponseNormalizerFilter, 14 | ], 15 | }) 16 | export class ResponseNormalizerModule {} 17 | -------------------------------------------------------------------------------- /src/app/http-api/response-normalizer/success-response-normalizer.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from "@nestjs/common"; 7 | import { Observable } from "rxjs"; 8 | import { map } from "rxjs/operators"; 9 | 10 | export interface Response { 11 | data: T; 12 | } 13 | 14 | @Injectable() 15 | export class SuccessResponseNormalizerInterceptor 16 | implements NestInterceptor> 17 | { 18 | intercept( 19 | context: ExecutionContext, 20 | next: CallHandler, 21 | ): Observable> { 22 | return next.handle().pipe(map((data: T) => ({ data }))); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/http-api/routes/route.constants.ts: -------------------------------------------------------------------------------- 1 | export const API = "api"; 2 | -------------------------------------------------------------------------------- /src/contexts/payments/application/create-payment-use-case/create-payment.dto.ts: -------------------------------------------------------------------------------- 1 | export interface CreatePaymentDto { 2 | amount: number; 3 | customerId: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/contexts/payments/application/create-payment-use-case/create-payment.use-case.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@/shared/dependency-injection/injectable"; 2 | 3 | import { 4 | Payment, 5 | PrimitivePayment, 6 | } from "@/contexts/payments/domain/payment.entity"; 7 | import { PaymentRepository } from "@/contexts/payments/domain/payment.repository"; 8 | 9 | import { CreatePaymentDto } from "./create-payment.dto"; 10 | 11 | @Injectable() 12 | export class CreatePaymentUseCase { 13 | constructor(private readonly paymentRepository: PaymentRepository) {} 14 | 15 | async run(dto: CreatePaymentDto): Promise<{ payment: PrimitivePayment }> { 16 | const payment = Payment.create(dto); 17 | 18 | await this.paymentRepository.save(payment); 19 | 20 | return { 21 | payment: payment.toPrimitives(), 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/contexts/payments/application/find-payment-by-id-use-case/find-payment-by-id.dto.ts: -------------------------------------------------------------------------------- 1 | export interface FindPaymentByIdDto { 2 | id: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/contexts/payments/application/find-payment-by-id-use-case/find-payment-by-id.use-case.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from "@nestjs/common"; 2 | 3 | import { FindPaymentByIdDto } from "@/contexts/payments/application/find-payment-by-id-use-case/find-payment-by-id.dto"; 4 | import { PrimitivePayment } from "@/contexts/payments/domain/payment.entity"; 5 | import { PaymentRepository } from "@/contexts/payments/domain/payment.repository"; 6 | import { PaymentNotFoundException } from "@/contexts/payments/domain/payment-not-found.exception"; 7 | 8 | @Injectable() 9 | export class FindPaymentByIdUseCase { 10 | constructor(private readonly paymentRepository: PaymentRepository) {} 11 | 12 | async run( 13 | findPaymentByIdDto: FindPaymentByIdDto, 14 | ): Promise<{ payment: PrimitivePayment }> { 15 | const payment = await this.paymentRepository.findById( 16 | findPaymentByIdDto.id, 17 | ); 18 | 19 | if (!payment) { 20 | throw new PaymentNotFoundException(findPaymentByIdDto.id); 21 | } 22 | 23 | return { 24 | payment: payment.toPrimitives(), 25 | }; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/contexts/payments/domain/payment-not-found.exception.ts: -------------------------------------------------------------------------------- 1 | export class PaymentNotFoundException extends Error { 2 | constructor(public readonly id: string) { 3 | super(`Payment not found ${id}`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/contexts/payments/domain/payment.entity.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | 3 | export interface PrimitivePayment { 4 | id: string; 5 | amount: number; 6 | customerId: string; 7 | } 8 | 9 | export class Payment { 10 | constructor(private attributes: PrimitivePayment) {} 11 | 12 | static create(createPayment: { 13 | amount: number; 14 | customerId: string; 15 | }): Payment { 16 | return new Payment({ 17 | id: uuidv4(), 18 | amount: createPayment.amount, 19 | customerId: createPayment.customerId, 20 | }); 21 | } 22 | 23 | toPrimitives(): PrimitivePayment { 24 | return { 25 | id: this.attributes.id, 26 | amount: this.attributes.amount, 27 | customerId: this.attributes.customerId, 28 | }; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/contexts/payments/domain/payment.repository.ts: -------------------------------------------------------------------------------- 1 | import { Payment } from "@/contexts/payments/domain/payment.entity"; 2 | 3 | export abstract class PaymentRepository { 4 | abstract save(payment: Payment): Promise; 5 | abstract findById(id: string): Promise; 6 | } 7 | -------------------------------------------------------------------------------- /src/contexts/payments/infrastructure/http-api/route.constants.ts: -------------------------------------------------------------------------------- 1 | export const V1_PAYMENTS = "v1/payments"; 2 | -------------------------------------------------------------------------------- /src/contexts/payments/infrastructure/http-api/v1/create-payment/create-payment.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post } from "@nestjs/common"; 2 | 3 | import { CreatePaymentUseCase } from "@/contexts/payments/application/create-payment-use-case/create-payment.use-case"; 4 | import { PrimitivePayment } from "@/contexts/payments/domain/payment.entity"; 5 | import { V1_PAYMENTS } from "@/contexts/payments/infrastructure/http-api/route.constants"; 6 | import { CreatePaymentHttpDto } from "@/contexts/payments/infrastructure/http-api/v1/create-payment/create-payment.http-dto"; 7 | 8 | @Controller(V1_PAYMENTS) 9 | export class CreatePaymentController { 10 | constructor(private readonly createPaymentUseCase: CreatePaymentUseCase) {} 11 | 12 | @Post() 13 | async run( 14 | @Body() createPaymentHttpDto: CreatePaymentHttpDto, 15 | ): Promise<{ payment: PrimitivePayment }> { 16 | return await this.createPaymentUseCase.run({ 17 | amount: createPaymentHttpDto.amount, 18 | customerId: createPaymentHttpDto.customerId, 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/contexts/payments/infrastructure/http-api/v1/create-payment/create-payment.http-dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsNumber, IsUUID } from "class-validator"; 2 | 3 | export class CreatePaymentHttpDto { 4 | @IsNumber() 5 | @IsNotEmpty() 6 | amount!: number; 7 | 8 | @IsUUID() 9 | @IsNotEmpty() 10 | customerId!: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/contexts/payments/infrastructure/http-api/v1/find-payment-by-id/find-payment-by-id.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, NotFoundException, Param } from "@nestjs/common"; 2 | 3 | import { FindPaymentByIdUseCase } from "@/contexts/payments/application/find-payment-by-id-use-case/find-payment-by-id.use-case"; 4 | import { PrimitivePayment } from "@/contexts/payments/domain/payment.entity"; 5 | import { PaymentNotFoundException } from "@/contexts/payments/domain/payment-not-found.exception"; 6 | import { V1_PAYMENTS } from "@/contexts/payments/infrastructure/http-api/route.constants"; 7 | import { FindPaymentByIdHttpDto } from "@/contexts/payments/infrastructure/http-api/v1/find-payment-by-id/find-payment-by-id.http-dto"; 8 | 9 | @Controller(V1_PAYMENTS) 10 | export class FindPaymentByIdController { 11 | constructor( 12 | private readonly findPaymentByIdUseCase: FindPaymentByIdUseCase, 13 | ) {} 14 | 15 | @Get(":id") 16 | async run( 17 | @Param() params: FindPaymentByIdHttpDto, 18 | ): Promise<{ payment: PrimitivePayment }> { 19 | try { 20 | return await this.findPaymentByIdUseCase.run({ 21 | id: params.id, 22 | }); 23 | } catch (error) { 24 | if (error instanceof PaymentNotFoundException) { 25 | throw new NotFoundException(error.message); 26 | } 27 | throw error; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/contexts/payments/infrastructure/http-api/v1/find-payment-by-id/find-payment-by-id.http-dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from "class-validator"; 2 | 3 | export class FindPaymentByIdHttpDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | id!: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/contexts/payments/infrastructure/payment.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from "@nestjs/common"; 2 | 3 | import { CreatePaymentUseCase } from "@/contexts/payments/application/create-payment-use-case/create-payment.use-case"; 4 | import { FindPaymentByIdUseCase } from "@/contexts/payments/application/find-payment-by-id-use-case/find-payment-by-id.use-case"; 5 | import { PaymentRepository } from "@/contexts/payments/domain/payment.repository"; 6 | import { CreatePaymentController } from "@/contexts/payments/infrastructure/http-api/v1/create-payment/create-payment.controller"; 7 | import { FindPaymentByIdController } from "@/contexts/payments/infrastructure/http-api/v1/find-payment-by-id/find-payment-by-id.controller"; 8 | import { InMemoryPaymentRepository } from "@/contexts/payments/infrastructure/repositories/in-memory.payment-repository"; 9 | 10 | @Module({ 11 | controllers: [CreatePaymentController, FindPaymentByIdController], 12 | providers: [ 13 | CreatePaymentUseCase, 14 | FindPaymentByIdUseCase, 15 | InMemoryPaymentRepository, 16 | { 17 | provide: PaymentRepository, 18 | useExisting: InMemoryPaymentRepository, 19 | }, 20 | ], 21 | exports: [CreatePaymentUseCase, FindPaymentByIdUseCase], 22 | }) 23 | export class PaymentModule {} 24 | -------------------------------------------------------------------------------- /src/contexts/payments/infrastructure/repositories/in-memory.payment-repository.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/require-await */ 2 | import { Injectable } from "@/shared/dependency-injection/injectable"; 3 | 4 | import { 5 | Payment, 6 | PrimitivePayment, 7 | } from "@/contexts/payments/domain/payment.entity"; 8 | import { PaymentRepository } from "@/contexts/payments/domain/payment.repository"; 9 | 10 | @Injectable() 11 | export class InMemoryPaymentRepository extends PaymentRepository { 12 | private payments: PrimitivePayment[] = []; 13 | 14 | async save(payment: Payment): Promise { 15 | this.payments.push(payment.toPrimitives()); 16 | } 17 | 18 | async findById(id: string): Promise { 19 | const payment = this.payments.find(payment => payment.id === id); 20 | return payment ? new Payment(payment) : null; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/contexts/shared/dependency-injection/injectable.ts: -------------------------------------------------------------------------------- 1 | import { Injectable as NestJsInjectable } from "@nestjs/common"; 2 | 3 | export function Injectable() { 4 | return NestJsInjectable(); 5 | } 6 | -------------------------------------------------------------------------------- /src/contexts/shared/logger/domain/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./logger"; 2 | -------------------------------------------------------------------------------- /src/contexts/shared/logger/domain/logger.ts: -------------------------------------------------------------------------------- 1 | export type Attributes = Record; 2 | 3 | export type Context = { attributes?: Attributes }; 4 | 5 | export type Message = string; 6 | 7 | export type LoggerLevel = "debug" | "info" | "warn" | "error" | "fatal"; 8 | 9 | export abstract class Logger { 10 | abstract debug(message: Message, context?: Context): void; 11 | abstract info(message: Message, context?: Context): void; 12 | abstract warn(message: Message, context?: Context): void; 13 | abstract error(message: Message, context?: Context): void; 14 | abstract fatal(message: Message, context?: Context): void; 15 | } 16 | -------------------------------------------------------------------------------- /src/contexts/shared/logger/infrastructure/logger.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, NestInterceptor } from "@nestjs/common"; 2 | import { FastifyReply, FastifyRequest } from "fastify"; 3 | import { Observable, tap } from "rxjs"; 4 | 5 | import { Injectable } from "@/shared/dependency-injection/injectable"; 6 | import { Logger } from "@/shared/logger/domain"; 7 | 8 | @Injectable() 9 | export class LoggerInterceptor implements NestInterceptor { 10 | constructor(private readonly logger: Logger) {} 11 | 12 | intercept(context: ExecutionContext, next: CallHandler): Observable { 13 | const type = context.getType(); 14 | 15 | switch (type) { 16 | case "http": { 17 | return this.logHttp(context, next); 18 | } 19 | default: 20 | } 21 | 22 | return next.handle(); 23 | } 24 | 25 | private logHttp( 26 | context: ExecutionContext, 27 | next: CallHandler, 28 | ): Observable { 29 | const req = context.switchToHttp().getRequest(); 30 | 31 | this.logger.info("Incoming http request", { 32 | attributes: { 33 | http: { 34 | url: req.url, 35 | method: req.method, 36 | urlQuery: req.query, 37 | userAgent: req.headers["user-agent"], 38 | }, 39 | attributes: { 40 | request: { 41 | body: req.body, 42 | params: req.params, 43 | }, 44 | }, 45 | }, 46 | }); 47 | 48 | return next.handle().pipe( 49 | tap({ 50 | next: data => { 51 | const res = context.switchToHttp().getResponse(); 52 | this.logger.info("Finishing http request", { 53 | attributes: { 54 | http: { 55 | statusCode: res.statusCode, 56 | }, 57 | request: { 58 | response: data, 59 | }, 60 | }, 61 | }); 62 | }, 63 | error: (error: unknown) => { 64 | this.logError("Finishing http request with error", error); 65 | }, 66 | }), 67 | ); 68 | } 69 | 70 | private logError(body: string, error: unknown) { 71 | const exceptionAttributes = 72 | error instanceof Error 73 | ? { 74 | message: error.message, 75 | type: error.name, 76 | stacktrace: error.stack, 77 | } 78 | : undefined; 79 | 80 | this.logger.error(body, { 81 | attributes: { 82 | exception: exceptionAttributes, 83 | }, 84 | }); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/contexts/shared/logger/infrastructure/logger.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module, Provider } from "@nestjs/common"; 2 | import { ConfigService } from "@nestjs/config"; 3 | 4 | import { Logger, LoggerLevel } from "@/shared/logger/domain"; 5 | 6 | import { LoggerInterceptor } from "./logger.interceptor"; 7 | import { NestLoggerService } from "./nestjs.logger-service"; 8 | import { PinoLogger, PinoLoggerDependencies } from "./pino.logger"; 9 | 10 | const loggerProvider: Provider = { 11 | provide: Logger, 12 | useFactory: (configService: ConfigService) => { 13 | const isLoggerEnabled = configService.get("LOGGER_ENABLED"); 14 | const loggerLevel = configService.get("LOGGER_LEVEL"); 15 | const dependencies: PinoLoggerDependencies = { 16 | isEnabled: isLoggerEnabled, 17 | level: loggerLevel, 18 | }; 19 | return new PinoLogger(dependencies); 20 | }, 21 | inject: [ConfigService], 22 | }; 23 | 24 | @Global() 25 | @Module({ 26 | providers: [loggerProvider, NestLoggerService, LoggerInterceptor], 27 | exports: [loggerProvider, NestLoggerService, LoggerInterceptor], 28 | }) 29 | export class LoggerModule {} 30 | -------------------------------------------------------------------------------- /src/contexts/shared/logger/infrastructure/nestjs.logger-service.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-member-access */ 2 | import { LoggerService } from "@nestjs/common"; 3 | 4 | import { Injectable } from "@/shared/dependency-injection/injectable"; 5 | import { Logger, LoggerLevel } from "@/shared/logger/domain"; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | type NestLoggerMessage = any; 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | type NestLoggerOptionalParams = any[]; 11 | 12 | @Injectable() 13 | export class NestLoggerService implements LoggerService { 14 | constructor(private readonly logger: Logger) {} 15 | 16 | verbose( 17 | message: NestLoggerMessage, 18 | ...optionalParams: NestLoggerOptionalParams 19 | ) { 20 | this.call("debug", message, ...optionalParams); 21 | } 22 | 23 | debug( 24 | message: NestLoggerMessage, 25 | ...optionalParams: NestLoggerOptionalParams[] 26 | ) { 27 | this.call("debug", message, ...optionalParams); 28 | } 29 | 30 | log( 31 | message: NestLoggerMessage, 32 | ...optionalParams: NestLoggerOptionalParams[] 33 | ) { 34 | this.call("info", message, ...optionalParams); 35 | } 36 | 37 | warn( 38 | message: NestLoggerMessage, 39 | ...optionalParams: NestLoggerOptionalParams[] 40 | ) { 41 | this.call("warn", message, ...optionalParams); 42 | } 43 | 44 | error( 45 | message: NestLoggerMessage, 46 | ...optionalParams: NestLoggerOptionalParams[] 47 | ) { 48 | this.call("error", message, ...optionalParams); 49 | } 50 | 51 | fatal( 52 | message: NestLoggerMessage, 53 | ...optionalParams: NestLoggerOptionalParams[] 54 | ) { 55 | this.call("fatal", message, ...optionalParams); 56 | } 57 | 58 | private call( 59 | level: LoggerLevel, 60 | message: NestLoggerMessage, 61 | ...optionalParams: NestLoggerOptionalParams[] 62 | ) { 63 | const loggerMessage = { 64 | message: typeof message === "string" ? message : message?.message, 65 | attributes: { 66 | ...(optionalParams.length > 0 67 | ? { context: optionalParams.at(-1) } 68 | : {}), 69 | ...(optionalParams.length > 0 ? optionalParams.slice(0, -1) : {}), 70 | }, 71 | }; 72 | 73 | this.logger[level](loggerMessage.message, { 74 | attributes: loggerMessage.attributes, 75 | }); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/contexts/shared/logger/infrastructure/pino.logger.ts: -------------------------------------------------------------------------------- 1 | import pino from "pino"; 2 | 3 | import { Injectable } from "@/shared/dependency-injection/injectable"; 4 | import { Context, Logger, LoggerLevel, Message } from "@/shared/logger/domain"; 5 | 6 | export interface PinoLoggerDependencies { 7 | isEnabled?: boolean; 8 | level?: LoggerLevel; 9 | } 10 | 11 | @Injectable() 12 | export class PinoLogger implements Logger { 13 | private readonly logger; 14 | 15 | constructor(dependencies: PinoLoggerDependencies) { 16 | this.logger = pino({ 17 | enabled: dependencies.isEnabled ?? true, 18 | messageKey: "message", 19 | level: this.getGetPinoLevelFrom(dependencies.level || "info"), 20 | formatters: { 21 | level: (label: string, level: number) => { 22 | return { 23 | severity: this.getSeverityLevel(label), 24 | level, 25 | }; 26 | }, 27 | }, 28 | base: undefined, 29 | }); 30 | } 31 | 32 | debug(message: Message, context?: Context): void { 33 | this.call("debug", message, context); 34 | } 35 | 36 | info(message: Message, context?: Context): void { 37 | this.call("info", message, context); 38 | } 39 | 40 | warn(message: Message, context?: Context): void { 41 | this.call("warn", message, context); 42 | } 43 | 44 | error(message: Message, context?: Context): void { 45 | this.call("error", message, context); 46 | } 47 | 48 | fatal(message: Message, context?: Context): void { 49 | this.call("fatal", message, context); 50 | } 51 | 52 | private call(level: pino.Level, message: Message, context?: Context) { 53 | const loggerMessage = { 54 | message, 55 | attributes: context?.attributes || {}, 56 | }; 57 | this.logger[level](loggerMessage); 58 | } 59 | 60 | private getSeverityLevel(label: string) { 61 | const pinoLevelToSeverityLookup: Record = { 62 | trace: "DEBUG", 63 | debug: "DEBUG", 64 | info: "INFO", 65 | warn: "WARNING", 66 | error: "ERROR", 67 | fatal: "CRITICAL", 68 | }; 69 | 70 | return ( 71 | pinoLevelToSeverityLookup[label as pino.Level] || 72 | pinoLevelToSeverityLookup.info 73 | ); 74 | } 75 | 76 | private getGetPinoLevelFrom(loggerLevel: LoggerLevel): pino.Level { 77 | const loggerLevelToPinoLevelMap: Record = { 78 | debug: "debug", 79 | info: "info", 80 | warn: "warn", 81 | error: "error", 82 | fatal: "fatal", 83 | }; 84 | 85 | return loggerLevelToPinoLevelMap[loggerLevel]; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from "@nestjs/common"; 2 | import { ConfigService } from "@nestjs/config"; 3 | import { NestFactory } from "@nestjs/core"; 4 | import { 5 | FastifyAdapter, 6 | NestFastifyApplication, 7 | } from "@nestjs/platform-fastify"; 8 | 9 | import { ErrorResponseNormalizerFilter } from "@/src/app/http-api/response-normalizer/error-response-normalizer.filter"; 10 | import { SuccessResponseNormalizerInterceptor } from "@/src/app/http-api/response-normalizer/success-response-normalizer.interceptor"; 11 | import { API } from "@/src/app/http-api/routes/route.constants"; 12 | 13 | import { Logger } from "@/shared/logger/domain"; 14 | import { LoggerInterceptor } from "@/shared/logger/infrastructure/logger.interceptor"; 15 | import { NestLoggerService } from "@/shared/logger/infrastructure/nestjs.logger-service"; 16 | 17 | import { AppModule } from "./app/app.module"; 18 | 19 | async function bootstrap() { 20 | const app = await NestFactory.create( 21 | AppModule, 22 | new FastifyAdapter(), 23 | { bufferLogs: true }, 24 | ); 25 | app.useLogger(app.get(NestLoggerService)); 26 | app.setGlobalPrefix(API); 27 | 28 | app.useGlobalFilters(app.get(ErrorResponseNormalizerFilter)); 29 | app.useGlobalInterceptors( 30 | app.get(LoggerInterceptor), 31 | app.get(SuccessResponseNormalizerInterceptor), 32 | ); 33 | app.useGlobalPipes( 34 | new ValidationPipe({ 35 | whitelist: true, 36 | forbidNonWhitelisted: true, 37 | transform: true, 38 | }), 39 | ); 40 | 41 | const configService = app.get(ConfigService); 42 | const port = configService.get("PORT", "3000"); 43 | const logger = app.get(Logger); 44 | 45 | await app.listen(port, "0.0.0.0"); 46 | 47 | logger.info(`App is ready and listening on port ${port} 🚀`); 48 | } 49 | 50 | bootstrap().catch(handleError); 51 | 52 | function handleError(error: unknown) { 53 | // eslint-disable-next-line no-console 54 | console.error(error); 55 | // eslint-disable-next-line unicorn/no-process-exit 56 | process.exit(1); 57 | } 58 | 59 | process.on("uncaughtException", handleError); 60 | -------------------------------------------------------------------------------- /tests/e2e/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbertHernandez/nestjs-hexagonal-architecture-example/dbb23feceb117c22f3ea04caee5bc20884203ae8/tests/e2e/.keep -------------------------------------------------------------------------------- /tests/unit/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AlbertHernandez/nestjs-hexagonal-architecture-example/dbb23feceb117c22f3ea04caee5bc20884203ae8/tests/unit/.keep -------------------------------------------------------------------------------- /tests/utils/mock.ts: -------------------------------------------------------------------------------- 1 | export { mock as createMock, MockProxy as Mock } from "vitest-mock-extended"; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "module": "commonjs", 5 | "target": "es2022", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "outDir": "dist", 9 | "sourceMap": true, 10 | "inlineSources": true, 11 | "emitDecoratorMetadata": true, 12 | "experimentalDecorators": true, 13 | "allowSyntheticDefaultImports": true, 14 | "skipLibCheck": true, 15 | "resolveJsonModule": true, 16 | "paths": { 17 | "@/src/*": ["src/*"], 18 | "@/http-api/*": ["src/http-api/*"], 19 | "@/contexts/*": ["src/contexts/*"], 20 | "@/users/*": ["src/contexts/users/*"], 21 | "@/shared/*": ["src/contexts/shared/*"], 22 | "@/tests/*": ["tests/*"] 23 | }, 24 | "types": ["vitest/globals"] 25 | }, 26 | "exclude": ["node_modules", "dist"] 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | } 5 | -------------------------------------------------------------------------------- /vitest.config.e2e.ts: -------------------------------------------------------------------------------- 1 | import swc from "unplugin-swc"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | import { createVitestTestConfig } from "./create-vitest-test-config"; 5 | 6 | export default defineConfig({ 7 | test: createVitestTestConfig("e2e"), 8 | plugins: [swc.vite()], 9 | }); 10 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import swc from "unplugin-swc"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | import { createVitestTestConfig } from "./create-vitest-test-config"; 5 | 6 | export default defineConfig({ 7 | test: createVitestTestConfig("(unit|e2e)"), 8 | plugins: [swc.vite()], 9 | }); 10 | -------------------------------------------------------------------------------- /vitest.config.unit.ts: -------------------------------------------------------------------------------- 1 | import swc from "unplugin-swc"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | import { createVitestTestConfig } from "./create-vitest-test-config"; 5 | 6 | export default defineConfig({ 7 | test: createVitestTestConfig("unit"), 8 | plugins: [swc.vite()], 9 | }); 10 | --------------------------------------------------------------------------------