├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── CleanArchitecture.jpg ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── config │ └── index.ts ├── index.ts ├── modules │ └── users │ │ ├── 1-domain-and-entities │ │ ├── user.spec.ts │ │ ├── user.ts │ │ ├── userEmail.spec.ts │ │ ├── userEmail.ts │ │ ├── userEvents.ts │ │ ├── userRole.spec.ts │ │ ├── userRole.ts │ │ └── userSubscriptions.ts │ │ ├── 2-use-cases │ │ ├── createUser │ │ │ ├── createUserDTOs.ts │ │ │ ├── createUserErrors.ts │ │ │ ├── createUserUseCase.spec.ts │ │ │ └── createUserUseCase.ts │ │ └── listUsers │ │ │ ├── listUsersDTOs.ts │ │ │ ├── listUsersUseCase.spec.ts │ │ │ └── listUsersUseCase.ts │ │ ├── 3-controllers-and-interface-adapters │ │ ├── UserMapper.ts │ │ ├── createUserController.spec.ts │ │ ├── createUserController.ts │ │ ├── dtos.ts │ │ ├── listUsersController.spec.ts │ │ └── listUsersController.ts │ │ └── 4-frameworks-and-drivers │ │ ├── http │ │ ├── routes.ts │ │ └── validators.ts │ │ ├── index.ts │ │ └── repositories │ │ ├── implementations │ │ └── inMemoryUserRepository.ts │ │ └── userRepository.ts └── shared │ ├── 1-domain-and-entities │ ├── DomainEvents.spec.ts │ └── domainEvents.ts │ ├── 3-controllers-and-interface-adapters │ └── BaseController.ts │ ├── 4-frameworks-and-drivers │ └── http │ │ ├── api │ │ └── v1.ts │ │ └── app.ts │ ├── textUtils │ └── validators.ts │ └── types │ ├── either.spec.ts │ ├── either.ts │ └── opaqueType.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 3 | extends: [ 4 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin 5 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 6 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 7 | ], 8 | parserOptions: { 9 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features 10 | sourceType: 'module', // Allows for the use of imports 11 | }, 12 | rules: { 13 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 14 | // e.g. "@typescript-eslint/explicit-function-return-type": "off", 15 | "@typescript-eslint/explicit-function-return-type": "off", 16 | "no-unused-vars": "off", 17 | "@typescript-eslint/no-unused-vars": ["error", { 18 | "vars": "all", 19 | "args": "after-used", 20 | "ignoreRestSiblings": true, 21 | "argsIgnorePattern": "^_" 22 | }] 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .tmp 3 | .idea 4 | .idea/ 5 | .DS_Store 6 | .env 7 | .version 8 | dist 9 | .vscode 10 | .history 11 | 12 | # Logs 13 | logs 14 | *.log 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | junit 26 | test-results.xml 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | lib-cov 30 | 31 | # Coverage directory used by tools like istanbul 32 | coverage 33 | 34 | # nyc test coverage 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 38 | .grunt 39 | 40 | # Bower dependency directory (https://bower.io/) 41 | bower_components 42 | 43 | # node-waf configuration 44 | .lock-wscript 45 | 46 | # Compiled binary addons (http://nodejs.org/api/addons.html) 47 | build/Release 48 | 49 | # Dependency directories 50 | node_modules/ 51 | jspm_packages/ 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.dev 71 | 72 | yarn.lock 73 | 74 | *.tsbuildinfo 75 | .version 76 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "typescript.tsdk": "node_modules/typescript/lib" 4 | } -------------------------------------------------------------------------------- /CleanArchitecture.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb/ddd-typescript/10dd84255891c54f4227c3fd283116283aa1353b/CleanArchitecture.jpg -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { pathsToModuleNameMapper } = require('ts-jest/utils'); 2 | const { compilerOptions } = require('./tsconfig'); 3 | 4 | 5 | module.exports = { 6 | preset: 'ts-jest', 7 | testEnvironment: 'node', 8 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/' + compilerOptions.baseUrl + '/' }) 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "2019-10-ddd-typescript", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "dev": "ts-node-dev -r tsconfig-paths/register src/index.ts", 7 | "lint": "eslint src/**/*.ts --fix", 8 | "test": "eslint src/**/*.ts --fix && jest" 9 | }, 10 | "keywords": [], 11 | "author": "Michał Miszczyszyn (https://typeofweb.com/)", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "@types/body-parser": "1.17.1", 15 | "@types/express": "4.17.1", 16 | "@types/hapi__hapi": "18.2.5", 17 | "@types/jest": "24.0.18", 18 | "@types/node": "12.7.12", 19 | "@typescript-eslint/eslint-plugin": "2.3.4-alpha.0", 20 | "@typescript-eslint/parser": "2.3.4-alpha.0", 21 | "eslint": "6.5.1", 22 | "eslint-config-prettier": "6.4.0", 23 | "eslint-plugin-prettier": "3.1.1", 24 | "husky": "3.0.8", 25 | "jest": "24.9.0", 26 | "lint-staged": "10.0.0-0", 27 | "prettier": "1.18.2", 28 | "ts-jest": "24.1.0", 29 | "tsconfig-paths": "3.9.0", 30 | "typescript": "3.7.0-beta" 31 | }, 32 | "dependencies": { 33 | "@hapi/hapi": "18.4.0", 34 | "body-parser": "1.19.0", 35 | "event-emitter3": "1.0.4", 36 | "express": "4.17.1", 37 | "typesafe-joi": "2.0.6" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | export const isProduction = process.env.DDD_FORUM_IS_PRODUCTION; 2 | 3 | export const port = process.env.PORT || 3000; 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './shared/4-frameworks-and-drivers/http/app'; 2 | -------------------------------------------------------------------------------- /src/modules/users/1-domain-and-entities/user.spec.ts: -------------------------------------------------------------------------------- 1 | import * as DomainEvents from '@shared/1-domain-and-entities/DomainEvents'; 2 | import { UserCreatedEvent } from './userEvents'; 3 | import { createUser, User } from './user'; 4 | import * as UserEmail from './userEmail'; 5 | import * as UserRole from './userRole'; 6 | import * as Either from '@shared/types/either'; 7 | 8 | describe('User', () => { 9 | describe('createUser', () => { 10 | it('should dispatch event when creating user', () => { 11 | const mock = jest.fn(); 12 | DomainEvents.listenToEvent('USER_CREATED', mock); 13 | 14 | const email = UserEmail.stringToUserEmail('michal.miszczyszyn@gmail.com'); 15 | Either.assertIsRight(email); 16 | const role = UserRole.stringToUserRole('admin'); 17 | Either.assertIsRight(role); 18 | 19 | const user = createUser({ 20 | email: email.value, 21 | role: role.value, 22 | }); 23 | 24 | DomainEvents.dispatchEventsForAggregate(User); 25 | 26 | expect(mock).toHaveBeenCalledTimes(1); 27 | expect(mock).toHaveBeenCalledWith('USER_CREATED', user.value); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/modules/users/1-domain-and-entities/user.ts: -------------------------------------------------------------------------------- 1 | import { UserEmail } from './userEmail'; 2 | import { UserRole } from './userRole'; 3 | import * as Either from '@shared/types/either'; 4 | import * as DomainEvents from '@shared/1-domain-and-entities/DomainEvents'; 5 | import { UserCreatedEvent } from './userEvents'; 6 | 7 | export type UserProps = { 8 | email: UserEmail; 9 | role: UserRole; 10 | }; 11 | 12 | export class User implements UserProps { 13 | constructor(public readonly email: UserEmail, public readonly role: UserRole) {} 14 | } 15 | 16 | export function createUser(props: UserProps): Either.Either { 17 | const user = new User(props.email, props.role); 18 | 19 | DomainEvents.addDomainEvent(User, 'USER_CREATED', user); 20 | 21 | return Either.makeRight(user); 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/users/1-domain-and-entities/userEmail.spec.ts: -------------------------------------------------------------------------------- 1 | import { stringToUserEmail, userEmailToString } from './userEmail'; 2 | import { assertIsRight, isLeft, isRight } from '@shared/types/either'; 3 | 4 | describe('UserEmail', () => { 5 | describe('stringToUserEmail', () => { 6 | it('should return Either.left if email is invalid', () => { 7 | const invalidEmail = stringToUserEmail('invalid email'); 8 | expect(isLeft(invalidEmail)).toBe(true); 9 | }); 10 | 11 | it('should return Either.right if email is valid', () => { 12 | const validEmail = stringToUserEmail('michal.miszczyszyn@miszczyszyn.michal.com'); 13 | expect(isRight(validEmail)).toBe(true); 14 | }); 15 | }); 16 | 17 | describe('userEmailToString', () => { 18 | it('should return string from UserEmail', () => { 19 | const validEmail = stringToUserEmail('michal@miszczyszyn.com'); 20 | assertIsRight(validEmail); 21 | 22 | expect(userEmailToString(validEmail.value)).toBe('michal@miszczyszyn.com'); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/modules/users/1-domain-and-entities/userEmail.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example of a Value Object implementation in TypeScript 3 | * with use of nominal (opaque) types 4 | */ 5 | import { branded } from '@shared/types/opaqueType'; 6 | import { Either, makeRight, makeLeft } from '@shared/types/either'; 7 | import { isValidEmail } from '@shared/textUtils/validators'; 8 | 9 | export class UserEmail extends branded() {} 10 | 11 | export function stringToUserEmail(email: string): Either { 12 | if (!isValidEmail(email)) { 13 | return makeLeft(new Error('Invalid email address!')); 14 | } 15 | return makeRight(UserEmail.toBranded(email)); 16 | } 17 | 18 | export function userEmailToString(email: UserEmail): string { 19 | return UserEmail.fromBranded(email); 20 | } 21 | -------------------------------------------------------------------------------- /src/modules/users/1-domain-and-entities/userEvents.ts: -------------------------------------------------------------------------------- 1 | import { User } from './user'; 2 | 3 | export type UserCreatedEvent = { 4 | type: 'USER_CREATED'; 5 | value: User; 6 | }; 7 | -------------------------------------------------------------------------------- /src/modules/users/1-domain-and-entities/userRole.spec.ts: -------------------------------------------------------------------------------- 1 | import { stringToUserRole, userRoleToString } from './userRole'; 2 | import { isLeft, isRight, assertIsRight } from '@shared/types/either'; 3 | 4 | describe('UserRole', () => { 5 | describe('stringToUserRole', () => { 6 | it('should return Either.left if role is invalid', () => { 7 | const invalidRole = stringToUserRole('invalid role'); 8 | expect(isLeft(invalidRole)).toBe(true); 9 | }); 10 | 11 | it('should return Either.right if role is valid', () => { 12 | const validRole = stringToUserRole('admin'); 13 | expect(isRight(validRole)).toBe(true); 14 | }); 15 | }); 16 | 17 | describe('userRoleToString', () => { 18 | it('should return string from UserRole', () => { 19 | const validRole = stringToUserRole('user'); 20 | assertIsRight(validRole); 21 | 22 | expect(userRoleToString(validRole.value)).toBe('user'); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/modules/users/1-domain-and-entities/userRole.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example of a Value Object implementation in TypeScript 3 | * with use of nominal (opaque) types 4 | */ 5 | import { branded } from '@shared/types/opaqueType'; 6 | import { Either, makeRight, makeLeft } from '@shared/types/either'; 7 | 8 | export class UserRole extends branded() {} 9 | 10 | function isValidUserRole(value: string) { 11 | return ['admin', 'user'].includes(value); 12 | } 13 | 14 | export function stringToUserRole(value: string): Either { 15 | if (!isValidUserRole(value)) { 16 | return makeLeft(new Error('Invalid user role!')); 17 | } 18 | return makeRight(UserRole.toBranded(value)); 19 | } 20 | 21 | export function userRoleToString(role: UserRole): string { 22 | return UserRole.fromBranded(role); 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/users/1-domain-and-entities/userSubscriptions.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb/ddd-typescript/10dd84255891c54f4227c3fd283116283aa1353b/src/modules/users/1-domain-and-entities/userSubscriptions.ts -------------------------------------------------------------------------------- /src/modules/users/2-use-cases/createUser/createUserDTOs.ts: -------------------------------------------------------------------------------- 1 | export interface CreateUserRequestDTO { 2 | email: string; 3 | } 4 | 5 | export type CreateUserResponseDTO = null; // @todo Response type ? 6 | -------------------------------------------------------------------------------- /src/modules/users/2-use-cases/createUser/createUserErrors.ts: -------------------------------------------------------------------------------- 1 | import { UserEmail, userEmailToString } from '@modules/users/1-domain-and-entities/userEmail'; 2 | 3 | export class EmailAlreadyExistsError extends Error { 4 | constructor(userEmail: UserEmail) { 5 | super(`User with email ${userEmailToString(userEmail)} already exists!`); 6 | this.name = 'EmailAlreadyExistsError'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/modules/users/2-use-cases/createUser/createUserUseCase.spec.ts: -------------------------------------------------------------------------------- 1 | import { CreateUserUseCase } from './createUserUseCase'; 2 | import { CreateUserRequestDTO } from './createUserDTOs'; 3 | import * as Either from '../../../../shared/types/either'; 4 | 5 | describe('CreateUserUseCase', () => { 6 | afterEach(() => jest.resetAllMocks()); 7 | 8 | const mockUserRepository = { 9 | existsByEmail: jest.fn().mockResolvedValue(false), 10 | save: jest.fn().mockResolvedValue(void 0), 11 | getAllUsers: jest.fn(), 12 | }; 13 | 14 | const createUserUseCase = CreateUserUseCase(mockUserRepository); 15 | 16 | it('should not error and persist on valid input', async () => { 17 | const mockRequest: CreateUserRequestDTO = { 18 | email: 'aaa@domain.com', 19 | }; 20 | 21 | const result = await createUserUseCase(mockRequest); 22 | 23 | expect(Either.isRight(result)).toBe(true); 24 | expect(mockUserRepository.save).toHaveBeenCalledWith({ 25 | email: 'aaa@domain.com', 26 | role: 'user', 27 | }); 28 | }); 29 | 30 | it('should error and not persist on invalid input', async () => { 31 | const mockRequest: CreateUserRequestDTO = { 32 | email: 'aaa', 33 | }; 34 | 35 | const result = await createUserUseCase(mockRequest); 36 | 37 | expect(Either.isLeft(result)).toBe(true); 38 | expect(mockUserRepository.save).not.toHaveBeenCalled(); 39 | }); 40 | 41 | it('should error and not persist if email exists', async () => { 42 | const mockRequest: CreateUserRequestDTO = { 43 | email: 'aaa@domain.com', 44 | }; 45 | 46 | mockUserRepository.existsByEmail.mockResolvedValue(true); 47 | 48 | const result = await createUserUseCase(mockRequest); 49 | 50 | expect(Either.isLeft(result)).toBe(true); 51 | expect(mockUserRepository.save).not.toHaveBeenCalled(); 52 | }); 53 | 54 | it('should error and not persist if repository throws on save', async () => { 55 | const mockRequest: CreateUserRequestDTO = { 56 | email: 'aaa@domain.com', 57 | }; 58 | 59 | mockUserRepository.save.mockRejectedValue('Errorrrrr'); 60 | 61 | const result = await createUserUseCase(mockRequest); 62 | 63 | expect(Either.isLeft(result)).toBe(true); 64 | }); 65 | 66 | it('should error and not persist if repository throws on existence check', async () => { 67 | const mockRequest: CreateUserRequestDTO = { 68 | email: 'aaa@domain.com', 69 | }; 70 | 71 | mockUserRepository.existsByEmail.mockRejectedValue('Errorrrrr'); 72 | 73 | const result = await createUserUseCase(mockRequest); 74 | 75 | expect(Either.isLeft(result)).toBe(true); 76 | expect(mockUserRepository.save).not.toHaveBeenCalled(); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/modules/users/2-use-cases/createUser/createUserUseCase.ts: -------------------------------------------------------------------------------- 1 | import { createUser } from '@modules/users/1-domain-and-entities/user'; 2 | import { stringToUserEmail } from '@modules/users/1-domain-and-entities/userEmail'; 3 | import { stringToUserRole } from '@modules/users/1-domain-and-entities/userRole'; 4 | import * as Either from '@shared/types/either'; 5 | import { EmailAlreadyExistsError } from './createUserErrors'; 6 | import { CreateUserRequestDTO, CreateUserResponseDTO } from './createUserDTOs'; 7 | import { UserRepository } from '@modules/users/4-frameworks-and-drivers/repositories/userRepository'; 8 | 9 | export const CreateUserUseCase = (userRepository: UserRepository) => async ( 10 | request: CreateUserRequestDTO, 11 | ): Promise> => { 12 | try { 13 | const emailOrError = stringToUserEmail(request.email); 14 | const roleOrError = stringToUserRole('user'); 15 | 16 | if (Either.isLeft(emailOrError)) { 17 | return emailOrError; 18 | } 19 | 20 | if (Either.isLeft(roleOrError)) { 21 | return roleOrError; 22 | } 23 | 24 | const userExists = await userRepository.existsByEmail(emailOrError.value); 25 | 26 | if (userExists) { 27 | return Either.makeLeft(new EmailAlreadyExistsError(emailOrError.value)); 28 | } 29 | 30 | const userOrError = createUser({ 31 | email: emailOrError.value, 32 | role: roleOrError.value, 33 | }); 34 | 35 | if (Either.isLeft(userOrError)) { 36 | return userOrError; 37 | } 38 | 39 | await userRepository.save(userOrError.value); 40 | return Either.makeRight(null); 41 | } catch (err) { 42 | return Either.makeLeft(err); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/modules/users/2-use-cases/listUsers/listUsersDTOs.ts: -------------------------------------------------------------------------------- 1 | import { UserDTO } from '@modules/users/3-controllers-and-interface-adapters/dtos'; 2 | 3 | export type ListUsersResponseDTO = { data: Array }; 4 | -------------------------------------------------------------------------------- /src/modules/users/2-use-cases/listUsers/listUsersUseCase.spec.ts: -------------------------------------------------------------------------------- 1 | import { ListUsersUseCase } from './listUsersUseCase'; 2 | import * as Either from '@shared/types/either'; 3 | 4 | describe('ListUsersUseCase', () => { 5 | afterEach(() => jest.resetAllMocks()); 6 | 7 | const mockUserRepository = { 8 | existsByEmail: jest.fn().mockResolvedValue(false), 9 | save: jest.fn().mockResolvedValue(void 0), 10 | getAllUsers: jest.fn(), 11 | }; 12 | 13 | const listUsersUseCase = ListUsersUseCase(mockUserRepository); 14 | 15 | it('should return empty users', async () => { 16 | mockUserRepository.getAllUsers.mockResolvedValue([]); 17 | const result = await listUsersUseCase(); 18 | 19 | expect(Either.isRight(result)).toBe(true); 20 | expect(result.value).toEqual([]); 21 | }); 22 | 23 | it('should return all users', async () => { 24 | mockUserRepository.getAllUsers.mockResolvedValue([{}, {}, {}]); 25 | const result = await listUsersUseCase(); 26 | 27 | expect(Either.isRight(result)).toBe(true); 28 | expect(result.value).toEqual([{}, {}, {}]); 29 | }); 30 | 31 | it('should return error when repository throws', async () => { 32 | mockUserRepository.getAllUsers.mockRejectedValue('Errorrrrr'); 33 | const result = await listUsersUseCase(); 34 | 35 | expect(Either.isLeft(result)).toBe(true); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/modules/users/2-use-cases/listUsers/listUsersUseCase.ts: -------------------------------------------------------------------------------- 1 | import * as Either from '@shared/types/either'; 2 | import { UserRepository } from '@modules/users/4-frameworks-and-drivers/repositories/userRepository'; 3 | import { User } from '@modules/users/1-domain-and-entities/user'; 4 | 5 | export const ListUsersUseCase = (userRepository: UserRepository) => async (): Promise< 6 | Either.Either 7 | > => { 8 | try { 9 | const users = await userRepository.getAllUsers(); 10 | return Either.makeRight(users); 11 | } catch (err) { 12 | return Either.makeLeft(err); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/modules/users/3-controllers-and-interface-adapters/UserMapper.ts: -------------------------------------------------------------------------------- 1 | import { isLeft } from '@shared/types/either'; 2 | import { UserDTO } from './dtos'; 3 | import { User } from '@modules/users/1-domain-and-entities/user'; 4 | import { RawUser } from '@modules/users/4-frameworks-and-drivers/repositories/userRepository'; 5 | import { 6 | stringToUserEmail, 7 | userEmailToString, 8 | } from '@modules/users/1-domain-and-entities/userEmail'; 9 | import { stringToUserRole, userRoleToString } from '@modules/users/1-domain-and-entities/userRole'; 10 | 11 | export function userToDomain(raw: RawUser): User { 12 | const email = stringToUserEmail(raw.email); 13 | const role = stringToUserRole(raw.roleId); 14 | 15 | if (isLeft(email)) { 16 | throw new Error('Invalid user email: ' + email.value); 17 | } 18 | 19 | if (isLeft(role)) { 20 | throw new Error('Invalid user role: ' + role.value); 21 | } 22 | 23 | return { 24 | email: email.value, 25 | role: role.value, 26 | }; 27 | } 28 | 29 | export function userToPersistence(user: User): RawUser { 30 | return { 31 | email: userEmailToString(user.email), 32 | roleId: userRoleToString(user.role), 33 | }; 34 | } 35 | 36 | export function userToDto(user: User): UserDTO { 37 | return { 38 | email: userEmailToString(user.email), 39 | role: userRoleToString(user.role), 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/modules/users/3-controllers-and-interface-adapters/createUserController.spec.ts: -------------------------------------------------------------------------------- 1 | import { CreateUserController } from './createUserController'; 2 | import * as Either from '@shared/types/either'; 3 | import { EmailAlreadyExistsError } from '@modules/users/2-use-cases/createUser/createUserErrors'; 4 | import { stringToUserEmail } from '../1-domain-and-entities/userEmail'; 5 | 6 | describe('CreateUserController', () => { 7 | const mockCreateUserUseCase = jest.fn().mockResolvedValue(Either.makeRight(null)); 8 | const createUserController = CreateUserController(mockCreateUserUseCase); 9 | 10 | it('should pass email to create user', async () => { 11 | const mockRequest = { body: { email: 'test@email.xyza' } }; 12 | await createUserController(mockRequest); 13 | expect(mockCreateUserUseCase).toHaveBeenCalledWith({ email: 'test@email.xyza' }); 14 | }); 15 | 16 | it('should error if user exists', async () => { 17 | const mockRequest = { body: { email: 'test@email.xyza' } }; 18 | const email = stringToUserEmail('test@email.xyza'); 19 | Either.assertIsRight(email); 20 | const error = new EmailAlreadyExistsError(email.value); 21 | mockCreateUserUseCase.mockResolvedValue(Either.makeLeft(error)); 22 | await expect(createUserController(mockRequest)).rejects.toMatchObject({ 23 | message: 'User with email test@email.xyza already exists!', 24 | status: 409, 25 | }); 26 | }); 27 | 28 | it('should error if something goes wrong', async () => { 29 | const mockRequest = { body: { email: 'test@email.xyza' } }; 30 | const error = new Error(); 31 | mockCreateUserUseCase.mockResolvedValue(Either.makeLeft(error)); 32 | await expect(createUserController(mockRequest)).rejects.toHaveProperty('message', ''); 33 | }); 34 | 35 | it(`should error if there's an exception`, async () => { 36 | const mockRequest = { body: { email: 'test@email.xyza' } }; 37 | mockCreateUserUseCase.mockRejectedValue(new Error('Errorrr')); 38 | await expect(createUserController(mockRequest)).rejects.toHaveProperty('message', 'Errorrr'); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/modules/users/3-controllers-and-interface-adapters/createUserController.ts: -------------------------------------------------------------------------------- 1 | import { CreateUserUseCase } from '@modules/users/2-use-cases/createUser/createUserUseCase'; 2 | import { CreateUserRequestDTO } from '@modules/users/2-use-cases/createUser/createUserDTOs'; 3 | import * as BaseController from '@shared/3-controllers-and-interface-adapters/BaseController'; 4 | import { EmailAlreadyExistsError } from '@modules/users/2-use-cases/createUser/createUserErrors'; 5 | import * as Either from '@shared/types/either'; 6 | 7 | export const CreateUserController = ( 8 | createUserUseCase: ReturnType, 9 | ): BaseController.BaseController => async req => { 10 | const dto = req.body; 11 | 12 | const result = await createUserUseCase({ 13 | email: dto.email, 14 | }); 15 | 16 | if (Either.isLeft(result)) { 17 | if (result.value instanceof EmailAlreadyExistsError) { 18 | return BaseController.conflict(result.value.message); 19 | } else { 20 | return BaseController.internal(); 21 | } 22 | } 23 | 24 | return BaseController.ok(result.value); 25 | }; 26 | -------------------------------------------------------------------------------- /src/modules/users/3-controllers-and-interface-adapters/dtos.ts: -------------------------------------------------------------------------------- 1 | export interface UserDTO { 2 | email: string; 3 | role: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/modules/users/3-controllers-and-interface-adapters/listUsersController.spec.ts: -------------------------------------------------------------------------------- 1 | import { ListUsersController } from './listUsersController'; 2 | import * as Either from '@shared/types/either'; 3 | 4 | describe('ListUsersController', () => { 5 | const mockListUsersUseCase = jest.fn().mockResolvedValue(Either.makeRight([])); 6 | const listUsersController = ListUsersController(mockListUsersUseCase); 7 | const mockRequest = { body: undefined }; 8 | 9 | it('should return users', async () => { 10 | mockListUsersUseCase.mockResolvedValue( 11 | Either.makeRight([ 12 | { email: 'test1@email.com', roleId: 'user' }, 13 | { email: 'test2@email.com', roleId: 'admin' }, 14 | ]), 15 | ); 16 | const result = await listUsersController(mockRequest); 17 | expect(mockListUsersUseCase).toHaveBeenCalled(); 18 | expect(result).toEqual({ 19 | body: { 20 | data: [ 21 | { email: 'test1@email.com', role: undefined }, 22 | { email: 'test2@email.com', role: undefined }, 23 | ], 24 | }, 25 | status: 200, 26 | }); 27 | }); 28 | 29 | it('should error if something goes wrong', async () => { 30 | const error = new Error(); 31 | mockListUsersUseCase.mockResolvedValue(Either.makeLeft(error)); 32 | await expect(listUsersController(mockRequest)).rejects.toHaveProperty('message', ''); 33 | }); 34 | 35 | it(`should error if there's an exception`, async () => { 36 | mockListUsersUseCase.mockRejectedValue(new Error('Errorrr')); 37 | await expect(listUsersController(mockRequest)).rejects.toHaveProperty('message', 'Errorrr'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/modules/users/3-controllers-and-interface-adapters/listUsersController.ts: -------------------------------------------------------------------------------- 1 | import { ListUsersUseCase } from '../2-use-cases/listUsers/listUsersUseCase'; 2 | import { ListUsersResponseDTO } from '../2-use-cases/listUsers/listUsersDTOs'; 3 | import * as BaseController from '@shared/3-controllers-and-interface-adapters/BaseController'; 4 | import * as Either from '@shared/types/either'; 5 | import { userToDto } from '@modules/users/3-controllers-and-interface-adapters/UserMapper'; 6 | 7 | export const ListUsersController = ( 8 | listUsersUseCase: ReturnType, 9 | ): BaseController.BaseController => async _req => { 10 | const result = await listUsersUseCase(); 11 | 12 | if (Either.isLeft(result)) { 13 | return BaseController.internal(); 14 | } 15 | 16 | const usersDto = { data: result.value.map(userToDto) }; 17 | return BaseController.ok(usersDto); 18 | }; 19 | -------------------------------------------------------------------------------- /src/modules/users/4-frameworks-and-drivers/http/routes.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { postUserValidator } from './validators'; 3 | import { createUserController, listUsersController } from '../index'; 4 | 5 | export const userRouter = express.Router(); 6 | 7 | userRouter.post('/', async (req, res) => { 8 | try { 9 | const validatedReq = postUserValidator.validate(req); 10 | 11 | if (validatedReq.error) { 12 | return res.status(400).json(validatedReq.error.message); 13 | } 14 | 15 | const response = await createUserController(validatedReq.value); 16 | 17 | if (response.body == null) { 18 | return res.status(response.status).end(); 19 | } 20 | 21 | return res.status(response.status).json(response.body); 22 | } catch (err) { 23 | return res.status(err.status || 500).json({ message: err.message || err }); 24 | } 25 | }); 26 | 27 | userRouter.get('/', async (req, res) => { 28 | try { 29 | const response = await listUsersController(req); 30 | return res.status(response.status).json(response.body); 31 | } catch (err) { 32 | return res.status(err.status || 500).json({ message: err.message || err }); 33 | } 34 | }); 35 | 36 | // @todo add hapi 37 | -------------------------------------------------------------------------------- /src/modules/users/4-frameworks-and-drivers/http/validators.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'typesafe-joi'; 2 | 3 | export const postUserValidator = Joi.object({ 4 | body: Joi.object({ 5 | email: Joi.string().required(), 6 | }).required(), 7 | }) 8 | .unknown(true) 9 | .required(); 10 | -------------------------------------------------------------------------------- /src/modules/users/4-frameworks-and-drivers/index.ts: -------------------------------------------------------------------------------- 1 | import { inMemoryUserRepository } from '@modules/users/4-frameworks-and-drivers/repositories/implementations/inMemoryUserRepository'; 2 | import { CreateUserController } from '@modules/users/3-controllers-and-interface-adapters/createUserController'; 3 | import { CreateUserUseCase } from '@modules/users/2-use-cases/createUser/createUserUseCase'; 4 | import { ListUsersController } from '@modules/users/3-controllers-and-interface-adapters/listUsersController'; 5 | import { ListUsersUseCase } from '@modules/users/2-use-cases/listUsers/listUsersUseCase'; 6 | 7 | const createUserUseCase = CreateUserUseCase(inMemoryUserRepository); 8 | export const createUserController = CreateUserController(createUserUseCase); 9 | 10 | const listUsersUseCase = ListUsersUseCase(inMemoryUserRepository); 11 | export const listUsersController = ListUsersController(listUsersUseCase); 12 | -------------------------------------------------------------------------------- /src/modules/users/4-frameworks-and-drivers/repositories/implementations/inMemoryUserRepository.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@modules/users/1-domain-and-entities/user'; 2 | import { 3 | userToPersistence, 4 | userToDomain, 5 | } from '@modules/users/3-controllers-and-interface-adapters/UserMapper'; 6 | import { userEmailToString } from '@modules/users/1-domain-and-entities/userEmail'; 7 | import * as DomainEvents from '@shared/1-domain-and-entities/DomainEvents'; 8 | import { 9 | UserRepository, 10 | RawUser, 11 | } from '@modules/users/4-frameworks-and-drivers/repositories/userRepository'; 12 | 13 | const users: RawUser[] = []; 14 | 15 | export const inMemoryUserRepository: UserRepository = { 16 | async existsByEmail(email): Promise { 17 | const emailStr = userEmailToString(email); 18 | return users.some(u => u.email === emailStr); 19 | }, 20 | 21 | async save(user): Promise { 22 | const userToSave = userToPersistence(user); 23 | users.push(userToSave); 24 | 25 | DomainEvents.dispatchEventsForAggregate(User); 26 | }, 27 | 28 | async getAllUsers(): Promise { 29 | return users.map(userToDomain); 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /src/modules/users/4-frameworks-and-drivers/repositories/userRepository.ts: -------------------------------------------------------------------------------- 1 | import { UserEmail } from '@modules/users/1-domain-and-entities/userEmail'; 2 | import { User } from '@modules/users/1-domain-and-entities/user'; 3 | 4 | export type RawUser = { 5 | email: string; 6 | roleId: string; 7 | }; 8 | 9 | export interface UserRepository { 10 | existsByEmail(email: UserEmail): Promise; 11 | save(user: User): Promise; 12 | getAllUsers(): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/1-domain-and-entities/DomainEvents.spec.ts: -------------------------------------------------------------------------------- 1 | import * as DomainEvents from './DomainEvents'; 2 | 3 | describe('DomainEvents', () => { 4 | class MyAggregate {} 5 | 6 | afterEach(() => { 7 | DomainEvents.clearEventsForAggregate(MyAggregate); 8 | jest.resetAllMocks(); 9 | }); 10 | 11 | it('should add event but not trigger it', () => { 12 | const fn = jest.fn(); 13 | DomainEvents.listenToEvent('MY_EVENT', fn); 14 | 15 | DomainEvents.addDomainEvent(MyAggregate, 'MY_EVENT', 'siema'); 16 | expect(fn).not.toHaveBeenCalled(); 17 | }); 18 | 19 | it('should trigger event ', () => { 20 | const fn = jest.fn(); 21 | DomainEvents.listenToEvent('MY_EVENT', fn); 22 | 23 | DomainEvents.addDomainEvent(MyAggregate, 'MY_EVENT', 'siema'); 24 | DomainEvents.dispatchEventsForAggregate(MyAggregate); 25 | expect(fn).toHaveBeenCalledWith('MY_EVENT', 'siema'); 26 | }); 27 | 28 | it('should trigger all events ', () => { 29 | const fn1 = jest.fn(); 30 | const fn2 = jest.fn(); 31 | const fn3 = jest.fn(); 32 | DomainEvents.listenToEvent('MY_EVENT1', fn1); 33 | DomainEvents.listenToEvent('MY_EVENT2', fn2); 34 | DomainEvents.listenToEvent('MY_EVENT3', fn3); 35 | DomainEvents.addDomainEvent(MyAggregate, 'MY_EVENT1', '1'); 36 | DomainEvents.addDomainEvent(MyAggregate, 'MY_EVENT2', '2'); 37 | DomainEvents.addDomainEvent(MyAggregate, 'MY_EVENT3', '3'); 38 | 39 | DomainEvents.dispatchEventsForAggregate(MyAggregate); 40 | 41 | expect(fn1).toHaveBeenCalledWith('MY_EVENT1', '1'); 42 | expect(fn2).toHaveBeenCalledWith('MY_EVENT2', '2'); 43 | expect(fn3).toHaveBeenCalledWith('MY_EVENT3', '3'); 44 | }); 45 | 46 | it('should trigger the same event multiple times', () => { 47 | const fn = jest.fn(); 48 | DomainEvents.listenToEvent('MY_EVENT', fn); 49 | DomainEvents.addDomainEvent(MyAggregate, 'MY_EVENT', '1'); 50 | DomainEvents.addDomainEvent(MyAggregate, 'MY_EVENT', '2'); 51 | DomainEvents.addDomainEvent(MyAggregate, 'MY_EVENT', '3'); 52 | 53 | DomainEvents.dispatchEventsForAggregate(MyAggregate); 54 | 55 | expect(fn).toHaveBeenCalledTimes(3); 56 | expect(fn).toHaveBeenCalledWith('MY_EVENT', '1'); 57 | expect(fn).toHaveBeenCalledWith('MY_EVENT', '2'); 58 | expect(fn).toHaveBeenCalledWith('MY_EVENT', '3'); 59 | }); 60 | 61 | it('should trigger the same event only once', () => { 62 | const fn = jest.fn(); 63 | DomainEvents.listenToEvent('MY_EVENT', fn); 64 | DomainEvents.addDomainEvent(MyAggregate, 'MY_EVENT', '1'); 65 | 66 | DomainEvents.dispatchEventsForAggregate(MyAggregate); 67 | DomainEvents.dispatchEventsForAggregate(MyAggregate); 68 | DomainEvents.dispatchEventsForAggregate(MyAggregate); 69 | 70 | expect(fn).toHaveBeenCalledTimes(1); 71 | }); 72 | 73 | it('should trigger only events for given aggregate', () => { 74 | class MyOtherAggregate {} 75 | 76 | const fn1 = jest.fn(); 77 | DomainEvents.listenToEvent('MY_EVENT', fn1); 78 | DomainEvents.listenToEvent('MY_EVENT2', fn1); 79 | DomainEvents.addDomainEvent(MyAggregate, 'MY_EVENT', '1'); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/shared/1-domain-and-entities/domainEvents.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'event-emitter3'; 2 | 3 | const DomainEventsHub = new EventEmitter(); 4 | 5 | type EventBase = { 6 | type: EventName; 7 | value: Value; 8 | }; 9 | 10 | interface Aggregate { 11 | new (...args: any): any; 12 | } 13 | 14 | const eventsByAggregate = new Map[]>(); 15 | 16 | export function addDomainEvent>( 17 | aggregate: Aggregate, 18 | eventName: Event['type'], 19 | value: Event['value'], 20 | ): void { 21 | const events = getEventsForAggregate(aggregate); 22 | const newEvents = [...events, { type: eventName, value }]; 23 | eventsByAggregate.set(aggregate, newEvents); 24 | } 25 | 26 | export function dispatchEventsForAggregate(aggregate: Aggregate) { 27 | getEventsForAggregate(aggregate).forEach(e => { 28 | DomainEventsHub.emit(e.type, e.value); 29 | }); 30 | clearEventsForAggregate(aggregate); 31 | } 32 | 33 | export function getEventsForAggregate(aggregate: Aggregate) { 34 | if (!eventsByAggregate.has(aggregate)) { 35 | return []; 36 | } 37 | return eventsByAggregate.get(aggregate)!; 38 | } 39 | 40 | export function clearEventsForAggregate(aggregate: Aggregate) { 41 | eventsByAggregate.set(aggregate, []); 42 | } 43 | 44 | export function listenToEvent>( 45 | eventName: Event['type'], 46 | handler: (eventName: Event['type'], value: Event['value']) => any, 47 | ): void { 48 | DomainEventsHub.on(eventName, data => handler(eventName, data)); 49 | } 50 | -------------------------------------------------------------------------------- /src/shared/3-controllers-and-interface-adapters/BaseController.ts: -------------------------------------------------------------------------------- 1 | interface Request { 2 | body: T; 3 | } 4 | 5 | export interface Response { 6 | status: number; 7 | body: T; 8 | } 9 | 10 | class HTTPError extends Error { 11 | constructor(message?: string, public status: number = 500) { 12 | super(message); 13 | this.name = 'HTTPError'; 14 | } 15 | } 16 | 17 | export type BaseController = ( 18 | req: Request, 19 | ) => Promise>; 20 | 21 | export function conflict(message?: string): never { 22 | throw new HTTPError(message, 409); 23 | } 24 | 25 | export function internal(message?: string): never { 26 | throw new HTTPError(message); 27 | } 28 | 29 | export function ok(response: T): Response; 30 | export function ok(response: unknown | null): Response { 31 | return { 32 | status: 200, 33 | body: response, 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/shared/4-frameworks-and-drivers/http/api/v1.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { userRouter } from '@modules/users/4-frameworks-and-drivers/http/routes'; 3 | 4 | export const v1Router = express.Router(); 5 | 6 | v1Router.use('/users', userRouter); 7 | -------------------------------------------------------------------------------- /src/shared/4-frameworks-and-drivers/http/app.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import bodyParser from 'body-parser'; 3 | import { v1Router } from './api/v1'; 4 | import { port } from '@config'; 5 | 6 | const app = express(); 7 | app.use(bodyParser.json()); 8 | 9 | app.use('/api/v1', v1Router); 10 | 11 | app.listen(port, () => { 12 | console.log(`[App]: Listening on port ${port}`); 13 | }); 14 | -------------------------------------------------------------------------------- /src/shared/textUtils/validators.ts: -------------------------------------------------------------------------------- 1 | export function isValidEmail(value: string) { 2 | return value.length > 2 && value.includes('@'); 3 | } 4 | -------------------------------------------------------------------------------- /src/shared/types/either.spec.ts: -------------------------------------------------------------------------------- 1 | import * as Either from './either'; 2 | 3 | describe('either', () => { 4 | it('left is left', () => { 5 | expect(Either.isLeft(Either.makeLeft(null))).toBe(true); 6 | }); 7 | 8 | it('right is right', () => { 9 | expect(Either.isRight(Either.makeRight(null))).toBe(true); 10 | }); 11 | 12 | it('right is not left', () => { 13 | expect(Either.isLeft(Either.makeRight(null))).toBe(false); 14 | }); 15 | 16 | it('left is not right', () => { 17 | expect(Either.isRight(Either.makeLeft(null))).toBe(false); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/shared/types/either.ts: -------------------------------------------------------------------------------- 1 | const eitherBrand = Symbol(); 2 | 3 | export type Either = Left | Right; 4 | 5 | type Left = { readonly [eitherBrand]: "left"; readonly value: L }; 6 | type Right<_, R> = { readonly [eitherBrand]: "right"; readonly value: R }; 7 | 8 | export const makeLeft = (l: L): Either => { 9 | return { [eitherBrand]: "left", value: l }; 10 | }; 11 | 12 | export const makeRight = (r: R): Either => { 13 | return { [eitherBrand]: "right", value: r }; 14 | }; 15 | 16 | export function isLeft(value: Either): value is Left { 17 | return value[eitherBrand] === "left"; 18 | } 19 | 20 | export function isRight(value: Either): value is Right { 21 | return value[eitherBrand] === "right"; 22 | } 23 | 24 | export function assertIsLeft(value: Either): asserts value is Left { 25 | if (value[eitherBrand] !== "left") { 26 | throw new Error(`Value is no left!`); 27 | } 28 | } 29 | 30 | export function assertIsRight(value: Either): asserts value is Right { 31 | if (value[eitherBrand] !== "right") { 32 | throw new Error(`Value is not right!`); 33 | } 34 | } 35 | 36 | // @todo update types 37 | // export function combine(value: Array>): Either { 38 | // return value.reduce((acc, val) => { 39 | // if (isLeft(acc)) { 40 | // return acc; 41 | // } 42 | // return val; 43 | // }, makeRight(null)) 44 | // }} 45 | -------------------------------------------------------------------------------- /src/shared/types/opaqueType.ts: -------------------------------------------------------------------------------- 1 | declare const $brand: unique symbol; 2 | 3 | export function branded() { 4 | return class Type { 5 | // tslint:disable-next-line 6 | // @ts-ignore 7 | private value!: Type; 8 | // tslint:disable-next-line 9 | // @ts-ignore 10 | private [$brand]: Brand; 11 | static toBranded(this: Cls, t: T) { 12 | return (t as unknown) as InstanceType; 13 | } 14 | static fromBranded(this: Cls, b: InstanceType) { 15 | return (b as unknown) as T; 16 | } 17 | static Type: Type; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "isolatedModules": true, 6 | 7 | "strict": true, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | 13 | "moduleResolution": "node", 14 | "baseUrl": "./src", 15 | "paths": { 16 | "@shared/*": ["shared/*"], 17 | "@config": ["config"], 18 | "@modules/*": ["modules/*"] 19 | }, 20 | "esModuleInterop": true 21 | } 22 | } 23 | --------------------------------------------------------------------------------