├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── check-pr-title.yml │ ├── conventional-label.yml │ └── nodejs.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg ├── pre-commit └── pre-push ├── .nvmrc ├── LICENSE ├── README.md ├── commitlint.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── main.ts ├── shared │ ├── domain │ │ ├── email-sender.ts │ │ └── logger.ts │ └── infrastructure │ │ ├── config.ts │ │ ├── dependencies.ts │ │ ├── email-sender │ │ └── fake-email-sender.ts │ │ ├── load-env-vars.ts │ │ └── logger │ │ └── console-logger.ts └── users │ ├── application │ └── welcome-message-sender.ts │ ├── domain │ ├── user-repository.ts │ └── user.ts │ └── infrastructure │ ├── dependencies.ts │ ├── rest-api │ ├── user-controller.ts │ └── user-router.ts │ └── user-repository │ ├── in-memory-user-repository.ts │ └── users.ts ├── tests └── users │ └── infrastructure │ └── user-repository │ └── in-memory-user-repository.test.ts ├── tsconfig.json └── tsconfig.prod.json /.env.example: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | coverage/ 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin', 'simple-import-sort'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | rules: { 13 | "simple-import-sort/imports": "error", 14 | "simple-import-sort/exports": "error", 15 | }, 16 | root: true, 17 | env: { 18 | node: true, 19 | jest: true, 20 | }, 21 | ignorePatterns: ['.eslintrc.js', "commitlint.config.js", "jest.config.js"], 22 | }; 23 | -------------------------------------------------------------------------------- /.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 | ## Types of changes 6 | 7 | What types of changes does your code introduce to Appium? 8 | _Put an `x` in the boxes that apply_ 9 | 10 | - [ ] Bugfix (non-breaking change which fixes an issue) 11 | - [ ] New feature (non-breaking change which adds functionality) 12 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 13 | - [ ] Documentation Update (if none of the other choices apply) 14 | 15 | ## Checklist 16 | 17 | _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._ 18 | 19 | - [ ] I have added tests that prove my fix is effective or that my feature works 20 | - [ ] I have added necessary documentation (if appropriate) 21 | - [ ] Any dependent changes have been merged and published in downstream modules 22 | 23 | ## Further comments 24 | 25 | 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... -------------------------------------------------------------------------------- /.github/workflows/check-pr-title.yml: -------------------------------------------------------------------------------- 1 | name: Check PR title 2 | on: 3 | pull_request: 4 | types: 5 | - opened 6 | - reopened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: aslafy-z/conventional-pr-title-action@v2.2.5 15 | with: 16 | success-state: Title follows the specification. 17 | failure-state: Title does not follow the specification. 18 | context-name: conventional-pr-title 19 | preset: conventional-changelog-angular@latest 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/conventional-label.yml: -------------------------------------------------------------------------------- 1 | name: conventional-release-labels 2 | 3 | on: 4 | pull_request_target: 5 | types: [ opened, edited ] 6 | 7 | jobs: 8 | label: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: bcoe/conventional-release-labels@v1 -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: Use Node.js 18 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: 18 14 | - name: npm install 15 | run: | 16 | npm install 17 | - name: npm run build 18 | run: | 19 | npm run build --if-present 20 | npm run lint 21 | - name: npm test 22 | run: | 23 | npm test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .tmp 4 | .idea 5 | .env 6 | coverage/ 7 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run test 5 | npm run build -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.0.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2020 Codely Enseña y Entretiene SL. https://codely.tv 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Hexagonal Architecture Typescript Service Skeleton

2 | 3 |

4 | Skeleton for new typescript services based on hexagonal architecture 5 |

6 | 7 |

8 | nodejs 9 |

10 | 11 | ## Table of Contents 12 | 13 | * [Installing](#installing) 14 | * [Building](#building) 15 | * [Testing](#testing) 16 | * [Linting](#linting) 17 | 18 | ## Installing 19 | 20 | ```bash 21 | nvm install 18.0.0 22 | nvm use 23 | npm install npm@8.3.0 -g 24 | npm install 25 | ``` 26 | 27 | ## Building 28 | 29 | ```bash 30 | npm run build 31 | ``` 32 | 33 | ## Testing 34 | 35 | ### Jest with Testing Library 36 | 37 | ```bash 38 | npm run test 39 | ``` 40 | 41 | ## Linting 42 | 43 | Run the linter 44 | 45 | ```bash 46 | npm run lint 47 | ``` 48 | 49 | Fix lint issues automatically 50 | 51 | ```bash 52 | npm run lint:fix 53 | ``` 54 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | cacheDirectory: '.tmp/jestCache' 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hexagonal-architecture-typescript-service-skeleton", 3 | "version": "1.0.0", 4 | "description": "Skeleton for new typescript services based on hexagonal architecture", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "node dist/src/main.js", 8 | "start:dev": "tsnd --respawn src/main.ts", 9 | "test": "npm run test:unit", 10 | "test:unit": "NODE_ENV=test jest --coverage", 11 | "prepare": "husky install", 12 | "lint": "eslint --ignore-path .gitignore . --ext .js,.ts", 13 | "lint:fix": "npm run lint -- --fix", 14 | "build": "npm run build:clean && npm run build:tsc", 15 | "build:clean": "rimraf dist; exit 0", 16 | "build:tsc": "tsc -p tsconfig.prod.json" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/AlbertHernandez/hexagonal-architecture-typescript-service-skeleton.git" 21 | }, 22 | "keywords": [ 23 | "typescript", 24 | "hexagonal architecture", 25 | "skeleton" 26 | ], 27 | "author": "alberthernandezdev@gmail.com", 28 | "license": "MIT", 29 | "engines": { 30 | "node": ">=18.0.0", 31 | "npm": ">=9.3.0" 32 | }, 33 | "bugs": { 34 | "url": "https://github.com/AlbertHernandez/hexagonal-architecture-typescript-service-skeleton/issues" 35 | }, 36 | "homepage": "https://github.com/AlbertHernandez/hexagonal-architecture-typescript-service-skeleton#readme", 37 | "devDependencies": { 38 | "@commitlint/cli": "^17.4.2", 39 | "@commitlint/config-conventional": "^17.4.2", 40 | "@types/express": "^4.17.15", 41 | "@types/jest": "^29.2.5", 42 | "@typescript-eslint/eslint-plugin": "^5.48.1", 43 | "@typescript-eslint/parser": "^5.48.1", 44 | "eslint": "^8.31.0", 45 | "eslint-config-prettier": "^8.6.0", 46 | "eslint-plugin-prettier": "^4.2.1", 47 | "eslint-plugin-simple-import-sort": "^8.0.0", 48 | "husky": "^8.0.3", 49 | "jest": "^29.3.1", 50 | "lint-staged": "^13.1.0", 51 | "prettier": "^2.8.3", 52 | "rimraf": "^4.0.4", 53 | "ts-jest": "^29.0.5", 54 | "ts-node-dev": "^2.0.0", 55 | "typescript": "^4.9.4" 56 | }, 57 | "lint-staged": { 58 | "*.(js|ts)": [ 59 | "npm run lint:fix" 60 | ] 61 | }, 62 | "dependencies": { 63 | "body-parser": "^1.20.1", 64 | "dotenv": "^16.0.3", 65 | "express": "^4.18.2" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import "./shared/infrastructure/load-env-vars"; 2 | 3 | import bodyParser from "body-parser"; 4 | import express from "express"; 5 | 6 | import { config } from "./shared/infrastructure/config"; 7 | import { userRouter } from "./users/infrastructure/rest-api/user-router"; 8 | 9 | function bootstrap() { 10 | const app = express(); 11 | 12 | app.use(bodyParser.json()); 13 | app.use("/users", userRouter); 14 | 15 | const { port } = config.server; 16 | 17 | app.listen(port, () => { 18 | console.log(`[APP] - Starting application on port ${port}`); 19 | }); 20 | } 21 | 22 | bootstrap(); 23 | -------------------------------------------------------------------------------- /src/shared/domain/email-sender.ts: -------------------------------------------------------------------------------- 1 | export interface EmailSender { 2 | sendMessage(email: string, text: string): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/domain/logger.ts: -------------------------------------------------------------------------------- 1 | interface MessageWithContext { 2 | message: string; 3 | context: Record; 4 | } 5 | 6 | type SimpleMessage = string; 7 | 8 | export type Message = SimpleMessage | MessageWithContext; 9 | 10 | export interface Logger { 11 | info(message: Message): void; 12 | error(message: Message): void; 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/infrastructure/config.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | server: { 3 | port: process.env.PORT || 3000, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /src/shared/infrastructure/dependencies.ts: -------------------------------------------------------------------------------- 1 | import { FakeEmailSender } from "./email-sender/fake-email-sender"; 2 | import { ConsoleLogger } from "./logger/console-logger"; 3 | 4 | export const logger = new ConsoleLogger(); 5 | export const emailSender = new FakeEmailSender(logger); 6 | -------------------------------------------------------------------------------- /src/shared/infrastructure/email-sender/fake-email-sender.ts: -------------------------------------------------------------------------------- 1 | import { EmailSender } from "../../domain/email-sender"; 2 | import { Logger } from "../../domain/logger"; 3 | 4 | export class FakeEmailSender implements EmailSender { 5 | constructor(private readonly logger: Logger) {} 6 | 7 | async sendMessage(email: string, text: string): Promise { 8 | this.logger.info( 9 | `[FakeEmailSender] - Sending email to "${email}": ${text}` 10 | ); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/infrastructure/load-env-vars.ts: -------------------------------------------------------------------------------- 1 | import { config } from "dotenv"; 2 | config(); 3 | -------------------------------------------------------------------------------- /src/shared/infrastructure/logger/console-logger.ts: -------------------------------------------------------------------------------- 1 | import { Logger, Message } from "../../domain/logger"; 2 | 3 | export class ConsoleLogger implements Logger { 4 | info(message: Message): void { 5 | console.log(message); 6 | } 7 | 8 | error(message: Message): void { 9 | console.error(message); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/users/application/welcome-message-sender.ts: -------------------------------------------------------------------------------- 1 | import { EmailSender } from "../../shared/domain/email-sender"; 2 | import { Logger } from "../../shared/domain/logger"; 3 | import { UserRepository } from "../domain/user-repository"; 4 | 5 | export class WelcomeMessageSender { 6 | constructor( 7 | private readonly userRepository: UserRepository, 8 | private readonly emailSender: EmailSender, 9 | private readonly logger: Logger 10 | ) {} 11 | 12 | async sendToUser(userId: string): Promise { 13 | this.logger.info( 14 | `[WelcomeMessageSender] - Sending welcome email to user: ${userId}` 15 | ); 16 | 17 | const user = await this.userRepository.getById(userId); 18 | 19 | if (!user) { 20 | const error = new Error(`User not found: ${userId}`); 21 | this.logger.error(error.message); 22 | throw error; 23 | } 24 | 25 | const message = "Welcome dev!"; 26 | await this.emailSender.sendMessage(user.email, message); 27 | 28 | this.logger.info( 29 | "[WelcomeMessageSender] - Successfully sent the welcome message to the user" 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/users/domain/user-repository.ts: -------------------------------------------------------------------------------- 1 | import { User } from "./user"; 2 | 3 | export interface UserRepository { 4 | getById(id: string): Promise; 5 | } 6 | -------------------------------------------------------------------------------- /src/users/domain/user.ts: -------------------------------------------------------------------------------- 1 | export class User { 2 | constructor( 3 | readonly id: string, 4 | readonly email: string, 5 | readonly slackUserId: string 6 | ) {} 7 | } 8 | -------------------------------------------------------------------------------- /src/users/infrastructure/dependencies.ts: -------------------------------------------------------------------------------- 1 | import { emailSender, logger } from "../../shared/infrastructure/dependencies"; 2 | import { WelcomeMessageSender } from "../application/welcome-message-sender"; 3 | import { UserController } from "./rest-api/user-controller"; 4 | import { InMemoryUserRepository } from "./user-repository/in-memory-user-repository"; 5 | 6 | const userRepository = new InMemoryUserRepository(); 7 | const welcomeEmailSender = new WelcomeMessageSender( 8 | userRepository, 9 | emailSender, 10 | logger 11 | ); 12 | 13 | export const userController = new UserController(welcomeEmailSender); 14 | -------------------------------------------------------------------------------- /src/users/infrastructure/rest-api/user-controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | 3 | import { WelcomeMessageSender } from "../../application/welcome-message-sender"; 4 | 5 | export class UserController { 6 | constructor(private readonly welcomeMessageSender: WelcomeMessageSender) {} 7 | 8 | async sendWelcomeMessage(req: Request, res: Response) { 9 | const { id: userId } = req.params; 10 | await this.welcomeMessageSender.sendToUser(userId); 11 | res.status(200).send(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/users/infrastructure/rest-api/user-router.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | 3 | import { userController } from "../dependencies"; 4 | 5 | const userRouter = express.Router(); 6 | 7 | userRouter.post( 8 | "/:id/welcome", 9 | userController.sendWelcomeMessage.bind(userController) 10 | ); 11 | 12 | export { userRouter }; 13 | -------------------------------------------------------------------------------- /src/users/infrastructure/user-repository/in-memory-user-repository.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../../domain/user"; 2 | import { UserRepository } from "../../domain/user-repository"; 3 | import { USERS } from "./users"; 4 | 5 | export class InMemoryUserRepository implements UserRepository { 6 | async getById(id: string): Promise { 7 | const user = USERS.find((user) => user.id === id); 8 | 9 | if (!user) { 10 | return null; 11 | } 12 | 13 | return new User(user.id, user.email, user.slackUserId); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/users/infrastructure/user-repository/users.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../../domain/user"; 2 | 3 | export const USERS: User[] = [ 4 | { 5 | id: "1", 6 | email: "albert@gmail.com", 7 | slackUserId: "albertSlackId", 8 | }, 9 | { 10 | id: "2", 11 | email: "juan@gmail.com", 12 | slackUserId: "juanSlackId", 13 | }, 14 | { 15 | id: "3", 16 | email: "pepe@gmail.com", 17 | slackUserId: "pepeSlackId", 18 | }, 19 | ]; 20 | -------------------------------------------------------------------------------- /tests/users/infrastructure/user-repository/in-memory-user-repository.test.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../../../../src/users/domain/user"; 2 | import { InMemoryUserRepository } from "../../../../src/users/infrastructure/user-repository/in-memory-user-repository"; 3 | 4 | describe("InMemoryUserRepository", () => { 5 | let repository: InMemoryUserRepository; 6 | 7 | beforeEach(() => { 8 | repository = new InMemoryUserRepository(); 9 | }); 10 | 11 | describe("getById", () => { 12 | it("should return the user when exists a user with that id", async () => { 13 | const existingUserId = "1"; 14 | expect(await repository.getById(existingUserId)).toBeInstanceOf(User); 15 | }); 16 | 17 | it("should return null when the user does not exist", async () => { 18 | const nonExistingUserId = "10"; 19 | expect(await repository.getById(nonExistingUserId)).toBeNull(); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "target": "es6", 6 | "noImplicitAny": true, 7 | "moduleResolution": "node", 8 | "sourceMap": false, 9 | "rootDir": ".", 10 | "strict": true, 11 | "noEmit": false, 12 | "resolveJsonModule": true, 13 | "noUnusedLocals": true, 14 | "outDir": "./dist" 15 | }, 16 | "include": [ 17 | "src/**/**.ts", 18 | "tests/**/**.ts", 19 | "playground/**/**.ts", 20 | ], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | }, 6 | "exclude": ["node_modules", "tests", "playground"] 7 | } 8 | --------------------------------------------------------------------------------