├── .gitignore ├── README.md ├── dev ├── Dockerfile └── init.sql ├── docker-compose.yml ├── docs └── logo.png ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── README.md ├── adapters │ ├── README.md │ ├── memoryDB │ │ ├── db.ts │ │ ├── index.ts │ │ └── repositories │ │ │ ├── book.ts │ │ │ ├── index.ts │ │ │ ├── loan.ts │ │ │ └── user.ts │ ├── postgres │ │ ├── asserts.ts │ │ ├── client.ts │ │ ├── index.ts │ │ ├── repositories │ │ │ ├── book.ts │ │ │ ├── index.ts │ │ │ ├── loan.ts │ │ │ └── user.ts │ │ └── util.ts │ └── rabbitmq │ │ └── index.ts ├── apps │ ├── README.md │ └── webapp │ │ └── index.ts ├── bindings │ ├── README.md │ └── express │ │ ├── index.ts │ │ └── route.ts └── lib │ ├── context │ ├── README.md │ ├── backend.ts │ ├── events.ts │ └── index.ts │ ├── entities │ ├── Book.ts │ ├── Loan.ts │ ├── README.md │ ├── User.ts │ ├── cast.ts │ └── index.ts │ ├── errors │ ├── README.md │ ├── book.ts │ ├── entities.ts │ ├── index.ts │ ├── loan.ts │ └── user.ts │ ├── events │ ├── Book.ts │ ├── Loan.ts │ ├── README.md │ ├── User.ts │ └── index.ts │ ├── index.ts │ └── operations │ ├── README.md │ ├── book.ts │ ├── index.ts │ ├── loan.ts │ └── user.ts ├── test ├── lib │ └── operations │ │ └── book.unit.spec.ts └── util │ └── mockCtx.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | .idea 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node TypeScript Architecture 2 | 3 | ![Logo](docs/logo.png) 4 | 5 | This is the companion responsibility for the [Node TypeScript Architecture GitBook](https://jbreckmckye.gitbook.io/node-ts-architecture/). 6 | 7 | NTA offers an opinionated architecture for writing Node.js applications, particularly in TypeScript (but will work with 8 | plain JS too). 9 | 10 | It offers a project structure, naming conventions, example code and recommended habits for building extendable, readable, 11 | reliable systems in Node.js. You can use this architecture to build backend apps, CLI programs, REST APIs, GraphQL boxes 12 | or even use it as part of an SPA. 13 | 14 | Learn more by [reading the docs](https://jbreckmckye.gitbook.io/node-ts-architecture/). 15 | -------------------------------------------------------------------------------- /dev/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 2 | WORKDIR /app 3 | 4 | COPY package.json yarn.lock tsconfig.json ./ 5 | RUN yarn 6 | COPY src ./src 7 | 8 | CMD yarn run ts-node -r tsconfig-paths/register src/apps/webapp/index.ts 9 | -------------------------------------------------------------------------------- /dev/init.sql: -------------------------------------------------------------------------------- 1 | -- Basic setup 2 | 3 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 4 | 5 | -- Tables 6 | 7 | CREATE TABLE IF NOT EXISTS users ( 8 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 9 | name text NOT NULL, 10 | address_line1 text NOT NULL, 11 | address_line2 text, 12 | postal_code text NOT NULL 13 | ); 14 | 15 | CREATE TABLE IF NOT EXISTS books ( 16 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 17 | name text NOT NULL 18 | ); 19 | 20 | CREATE TABLE IF NOT EXISTS loans ( 21 | id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 22 | user_id UUID NOT NULL REFERENCES users (id), 23 | book_id UUID NOT NULL REFERENCES books (id), 24 | returned boolean NOT NULL DEFAULT FALSE 25 | ); 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | container_name: webapp 6 | build: 7 | context: . 8 | dockerfile: dev/Dockerfile 9 | depends_on: 10 | - postgres 11 | - rabbitmq 12 | environment: 13 | DATABASE_URL: postgres://username:password@postgres:5432/db 14 | NODE_ENV: development 15 | PORT: 3000 16 | ports: 17 | - "3000:3000" 18 | 19 | postgres: 20 | image: postgres:12.2 21 | container_name: postgres 22 | volumes: 23 | - ./dev/init.sql:/docker-entrypoint-initdb.d/init.sql 24 | ports: 25 | - "5432:5432" 26 | environment: 27 | POSTGRES_USER: username 28 | POSTGRES_PASSWORD: password 29 | POSTGRES_DB: db 30 | 31 | 32 | rabbitmq: 33 | image: rabbitmq:3-management-alpine 34 | container_name: rabbitmq 35 | ports: 36 | - "5672:5672" 37 | - "15672:15672" 38 | environment: 39 | RABBITMQ_DEFAULT_USER: guest 40 | RABBITMQ_DEFAULT_PASS: guest 41 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jbreckmckye/node-typescript-architecture/e8ac2d8118c0bfefa0d033d5911857b8012dc5fd/docs/logo.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { pathsToModuleNameMapper } = require('ts-jest'); 2 | const { compilerOptions } = require('./tsconfig.json'); 3 | 4 | module.exports = { 5 | preset: 'ts-jest', 6 | testEnvironment: 'node', 7 | moduleNameMapper: pathsToModuleNameMapper( 8 | compilerOptions.paths, 9 | { prefix: '/src/' } 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-typescript-architecture", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "git@github.com:jbreckmckye/node-typescript-architecture.git", 6 | "author": "Jimmy Breck-McKye ", 7 | "license": "MIT", 8 | "scripts": { 9 | "start": "ts-node -r tsconfig-paths/register ./src/apps/webapp/index.ts" 10 | }, 11 | "devDependencies": { 12 | "@types/amqplib": "^0.10.1", 13 | "@types/jest": "^29.5.2", 14 | "@types/node": "^20.4.0", 15 | "@types/pg": "^8.10.2", 16 | "@types/uuid": "^9.0.2", 17 | "jest": "^29.6.0", 18 | "ts-jest": "^29.1.1", 19 | "ts-node": "^10.9.1", 20 | "typescript": "^5.1.6" 21 | }, 22 | "dependencies": { 23 | "@types/express": "^4.17.17", 24 | "amqplib": "^0.10.3", 25 | "body-parser": "^1.20.2", 26 | "express": "^4.18.2", 27 | "fp-ts": "^2.16.0", 28 | "io-ts": "^2.2.20", 29 | "io-ts-reporters": "^2.0.1", 30 | "io-ts-types": "^0.5.19", 31 | "monocle-ts": "^2.3.13", 32 | "newtype-ts": "^0.3.5", 33 | "pg": "^8.11.1", 34 | "tsconfig-paths": "^4.2.0", 35 | "uuid": "^9.0.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Project structure 2 | 3 | Each directory represents a key component of NTA: 4 | 5 | - The **lib** contains the 'core' domain code, and describes what types exist in the system (`entities`) and what a user can do with them (`operations`) 6 | - The **adapters** provide functions to help the lib's operations achieve side effects 7 | - The **bindings** take a lib plus adapters and make it responsive to messages from the outside world 8 | - Each folder in apps combines the lib with adapters and a binding to make something runnable 9 | 10 | These parts are explained in more detail on the [docs page on project structure](https://jbreckmckye.gitbook.io/node-ts-architecture/project-structure). -------------------------------------------------------------------------------- /src/adapters/README.md: -------------------------------------------------------------------------------- 1 | Learn about adapters [here](https://app.gitbook.com/@jbreckmckye/s/node-ts-architecture/creating-adapters/adapters-uncovered). -------------------------------------------------------------------------------- /src/adapters/memoryDB/db.ts: -------------------------------------------------------------------------------- 1 | import { Book, Loan, User } from '@lib/entities' 2 | 3 | export type MemoryDB = { 4 | books: Set, 5 | loans: Set, 6 | users: Set, 7 | } 8 | 9 | export function createDB (): MemoryDB { 10 | return { 11 | books: new Set(), 12 | loans: new Set(), 13 | users: new Set() 14 | } 15 | } 16 | 17 | export function withDB (db: MemoryDB, fn: (db: MemoryDB, input: I) => Promise): (input: I) => Promise { 18 | return function (input: I) { 19 | return fn(db, input) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/adapters/memoryDB/index.ts: -------------------------------------------------------------------------------- 1 | import { Ctx } from '@lib' 2 | import * as Repository from './repositories' 3 | import { createDB, withDB } from '@adapters/memoryDB/db' 4 | 5 | export async function $adapter (): Promise { 6 | const db = createDB() 7 | const backend = { 8 | bookStore: { 9 | add: withDB(db, Repository.Book.add), 10 | remove: withDB(db, Repository.Book.remove), 11 | find: withDB(db, Repository.Book.find) 12 | }, 13 | 14 | loanStore: { 15 | takeLoan: withDB(db, Repository.Loan.takeLoan), 16 | endLoan: withDB(db, Repository.Loan.endLoan), 17 | getUserLoans: withDB(db, Repository.Loan.getUserLoans), 18 | getLoan: withDB(db, Repository.Loan.getLoan) 19 | }, 20 | 21 | userStore: { 22 | add: withDB(db, Repository.User.add), 23 | remove: withDB(db, Repository.User.remove), 24 | find: withDB(db, Repository.User.find) 25 | } 26 | } 27 | 28 | return async function adapter (op: Ctx.Operation) { 29 | return { op, ctx: { backend }} 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/adapters/memoryDB/repositories/book.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid' 2 | import { UUID } from 'io-ts-types/lib/UUID' 3 | import { MemoryDB } from '../db' 4 | import { Book, BookInput, castBook } from '@lib/entities' 5 | 6 | export async function add (db: MemoryDB, input: BookInput): Promise { 7 | console.log('creating in-memory book') 8 | 9 | const book = castBook({ 10 | ...input, 11 | id: uuid() 12 | }) 13 | db.books.add(book) 14 | return book 15 | } 16 | 17 | export async function find (db: MemoryDB, input: UUID): Promise { 18 | for (const book of db.books) { 19 | if (book.id === input) { 20 | return castBook(book) 21 | } 22 | } 23 | return null 24 | } 25 | 26 | export async function remove (db: MemoryDB, input: Book): Promise { 27 | for (const book of db.books) { 28 | if (book.id === input.id) { 29 | db.books.delete(book) 30 | return 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/adapters/memoryDB/repositories/index.ts: -------------------------------------------------------------------------------- 1 | import * as Book from './book' 2 | import * as Loan from './loan' 3 | import * as User from './user' 4 | 5 | export { 6 | Book, 7 | Loan, 8 | User 9 | } 10 | -------------------------------------------------------------------------------- /src/adapters/memoryDB/repositories/loan.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid' 2 | import { MemoryDB } from '../db' 3 | import { Book, Loan, LoanInput, castLoan, User } from '@lib/entities' 4 | 5 | export async function takeLoan (db: MemoryDB, input: LoanInput): Promise { 6 | const loan = castLoan({ 7 | ...input, 8 | returned: false, 9 | id: uuid() 10 | }) 11 | 12 | db.loans.add(loan) 13 | return loan 14 | } 15 | 16 | export async function endLoan (db: MemoryDB, input: Loan): Promise { 17 | for (const loan of db.loans) { 18 | if (loan.id === input.id) { 19 | db.loans.delete(loan) 20 | db.loans.add({ 21 | ...loan, 22 | returned: true 23 | }) 24 | return loan 25 | } 26 | } 27 | throw new Error('No loan found in set') 28 | } 29 | 30 | export async function getUserLoans (db: MemoryDB, input: User): Promise { 31 | const loans = Array.from(db.loans) 32 | return loans.filter(loan => loan.userId === input.id && loan.returned === false) 33 | } 34 | 35 | export async function getLoan (db: MemoryDB, input: Book): Promise { 36 | for (const loan of db.loans) { 37 | if (loan.bookId === input.id && loan.returned == false) { 38 | return loan 39 | } 40 | } 41 | return null 42 | } 43 | -------------------------------------------------------------------------------- /src/adapters/memoryDB/repositories/user.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid' 2 | import { MemoryDB } from '../db' 3 | import { User, UserInput, castUser } from '@lib/entities' 4 | import { UUID } from 'io-ts-types/lib/UUID' 5 | 6 | export async function add (db: MemoryDB, input: UserInput): Promise { 7 | const user = castUser({ 8 | ...input, 9 | id: uuid() 10 | }) 11 | 12 | db.users.add(user) 13 | return user 14 | } 15 | 16 | export async function find (db: MemoryDB, input: UUID): Promise { 17 | for (const user of db.users) { 18 | if (user.id === input) return user 19 | } 20 | return null 21 | } 22 | 23 | export async function remove (db: MemoryDB, input: User): Promise { 24 | for (const user of db.users) { 25 | if (user.id === input.id) { 26 | db.users.delete(user) 27 | return 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/adapters/postgres/asserts.ts: -------------------------------------------------------------------------------- 1 | // These are just some simple helper functions to aid with reading the database 2 | // Their use is to explode if we get back an unexpected number of database rows 3 | // (which suggests something amiss with either the query or the data) 4 | 5 | export function justOne (rows: T[]): T { 6 | switch (rows.length) { 7 | case 0: 8 | throw new Error('Query should have returned 1 row, returned 0') 9 | case 1: 10 | return rows[0] 11 | default: 12 | throw new Error('Query should have returned 1 row, returned ' + rows.length) 13 | } 14 | } 15 | 16 | export function oneOrNone (rows: T[]): T | null { 17 | switch (rows.length) { 18 | case 0: 19 | return null 20 | case 1: 21 | return rows[0] 22 | default: 23 | throw new Error('Query should have returned 1 or 0 rows, returned ' + rows.length) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/adapters/postgres/client.ts: -------------------------------------------------------------------------------- 1 | import { Pool, PoolClient } from 'pg' 2 | 3 | export type DbFn = (client: PoolClient, input: I) => Promise 4 | 5 | export function createConnectionPool () { 6 | return new Pool({ 7 | max: 5, 8 | connectionTimeoutMillis: 100, 9 | idleTimeoutMillis: 500, 10 | host: 'postgres', 11 | user: 'username', 12 | password: 'password', 13 | database: 'db', 14 | port: 5432 15 | }) 16 | } 17 | 18 | // This is a simplified function for wrapping a callback in a database transaction. 19 | // If the callback throws an error, we roll back any changes to the database. 20 | // This includes non-DB errors being thrown - to prevent inconsistencies across the system 21 | 22 | export async function wrapTransaction (client: PoolClient, cb: () => Promise) { 23 | try { 24 | await client.query('BEGIN') 25 | const result = await cb() 26 | await client.query('COMMIT') 27 | return result 28 | 29 | } catch (e) { 30 | await client.query('ROLLBACK') 31 | throw e 32 | 33 | } finally { 34 | client.release() 35 | } 36 | } 37 | 38 | // Take a repository function of signature (client, input) => output and a (client) and returns a function (input) => output 39 | 40 | export function withClient (client: PoolClient, fn: DbFn) { 41 | return function (input: I): Promise { 42 | return fn(client, input) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/adapters/postgres/index.ts: -------------------------------------------------------------------------------- 1 | import * as Repository from './repositories' 2 | import { createConnectionPool, withClient, wrapTransaction } from './client' 3 | import { ContextAdapter, Operation } from '@lib/context' 4 | 5 | // The naming convention '$foo' means a function that returns another function 'foo' 6 | // Here we are preparing an adapter function that will inject dependencies into the library at runtime. 7 | // We call this a 'context adapter', because its output is passed as a 'context' parameter to the domain function. 8 | 9 | export async function $adapter (): Promise { 10 | // In the outer closure, you can set up resources that will be shared between requests. 11 | // Here we create a database connection pool we can re-use. 12 | 13 | const pool = createConnectionPool() 14 | 15 | return async function adapter (op: Operation) { 16 | // In this inner closure, we do work that has to be done per-request 17 | 18 | const client = await pool.connect() 19 | 20 | // WrapTransaction and WithClient are higher order functions we're using 21 | // to add database transactions and inject the db into the adapter DB functions ('Repositories') 22 | 23 | return { 24 | op: (ctx, input: I) => wrapTransaction(client, () => op(ctx, input)), 25 | ctx: { 26 | backend: { 27 | bookStore: { 28 | add: withClient(client, Repository.Book.add), 29 | find: withClient(client, Repository.Book.find), 30 | remove: withClient(client, Repository.Book.remove) 31 | }, 32 | 33 | loanStore: { 34 | takeLoan: withClient(client, Repository.Loan.takeLoan), 35 | endLoan: withClient(client, Repository.Loan.endLoan), 36 | getUserLoans: withClient(client, Repository.Loan.getUserLoans), 37 | getLoan: withClient(client, Repository.Loan.getLoan) 38 | }, 39 | 40 | userStore: { 41 | add: withClient(client, Repository.User.add), 42 | remove: withClient(client, Repository.User.remove), 43 | find: withClient(client, Repository.User.find) 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/adapters/postgres/repositories/book.ts: -------------------------------------------------------------------------------- 1 | import { PoolClient } from 'pg' 2 | import { UUID } from 'io-ts-types/lib/UUID' 3 | import { Book, BookInput, castBook } from '@lib/entities' 4 | import { justOne, oneOrNone } from '../asserts' 5 | 6 | export async function add (client: PoolClient, input: BookInput): Promise { 7 | const { rows } = await client.query({ 8 | text: ` 9 | INSERT INTO books ("name") 10 | VALUES ($1) 11 | RETURNING *`, 12 | values: [input.name] 13 | }) 14 | 15 | return justOne(rows.map(castBook)) 16 | } 17 | 18 | export async function find (client: PoolClient, input: UUID): Promise { 19 | const { rows } = await client.query({ 20 | text: ` 21 | SELECT * FROM books 22 | WHERE id = $1`, 23 | values: [input] 24 | }) 25 | 26 | return oneOrNone(rows.map(castBook)) 27 | } 28 | 29 | export async function remove (client: PoolClient, input: Book): Promise { 30 | await client.query({ 31 | text: ` 32 | DELETE FROM loans WHERE book_id = $1 33 | `, 34 | values: [input.id] 35 | }) 36 | 37 | await client.query({ 38 | text: ` 39 | DELETE FROM books WHERE id = $1 40 | `, 41 | values: [input.id] 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /src/adapters/postgres/repositories/index.ts: -------------------------------------------------------------------------------- 1 | import * as Book from './book' 2 | import * as Loan from './loan' 3 | import * as User from './user' 4 | 5 | export { 6 | Book, 7 | Loan, 8 | User 9 | } 10 | -------------------------------------------------------------------------------- /src/adapters/postgres/repositories/loan.ts: -------------------------------------------------------------------------------- 1 | import { PoolClient } from 'pg' 2 | import { Book, castLoan, Loan, LoanInput, User } from '@lib/entities' 3 | import { justOne, oneOrNone } from '../asserts' 4 | import { rowToObject } from '@adapters/postgres/util' 5 | 6 | export async function takeLoan (client: PoolClient, loanInput: LoanInput): Promise { 7 | const { rows } = await client.query({ 8 | text: ` 9 | INSERT INTO loans ("user_id", "book_id") 10 | VALUES ($1, $2) 11 | RETURNING *`, 12 | values: [loanInput.userId, loanInput.bookId] 13 | }) 14 | 15 | return justOne(rows.map(rowToObject).map(castLoan)) 16 | } 17 | 18 | export async function endLoan (client: PoolClient, loan: Loan): Promise { 19 | const { rows } = await client.query({ 20 | text: ` 21 | UPDATE loans SET returned = TRUE 22 | WHERE book_id = $1 AND returned = FALSE 23 | RETURNING *`, 24 | values: [loan.bookId] 25 | }) 26 | 27 | return justOne(rows.map(rowToObject).map(castLoan)) 28 | } 29 | 30 | export async function getUserLoans (client: PoolClient, user: User): Promise { 31 | const { rows } = await client.query({ 32 | text: ` 33 | SELECT * FROM loans 34 | WHERE user_id = $1 AND returned = FALSE`, 35 | values: [user.id] 36 | }) 37 | 38 | return rows.map(rowToObject).map(castLoan) 39 | } 40 | 41 | export async function getLoan (client: PoolClient, book: Book): Promise { 42 | const { rows } = await client.query({ 43 | text: ` 44 | SELECT * FROM loans 45 | WHERE book_id = $1 AND returned = FALSE`, 46 | values: [book.id] 47 | }) 48 | 49 | return oneOrNone(rows.map(rowToObject).map(castLoan)) 50 | } 51 | -------------------------------------------------------------------------------- /src/adapters/postgres/repositories/user.ts: -------------------------------------------------------------------------------- 1 | import { PoolClient } from 'pg' 2 | import { UUID } from 'io-ts-types/lib/UUID' 3 | import { castUser, User, UserInput } from '@lib/entities' 4 | import { justOne, oneOrNone } from '../asserts' 5 | 6 | export async function add (client: PoolClient, input: UserInput): Promise { 7 | const { name, address } = input 8 | const { rows } = await client.query({ 9 | text: ` 10 | INSERT INTO users ("name", "address_line1", "address_line2", "postal_code") 11 | VALUES ($1, $2, $3, $4) 12 | RETURNING *`, 13 | values: [name, address.line1, address.line2, address.postalCode] 14 | }) 15 | 16 | return justOne(rows.map(rowToUser).map(castUser)) 17 | } 18 | 19 | export async function find (client: PoolClient, input: UUID): Promise { 20 | const { rows } = await client.query({ 21 | text: ` 22 | SELECT * FROM users WHERE id = $1 23 | `, 24 | values: [input] 25 | }) 26 | 27 | return oneOrNone(rows.map(rowToUser).map(castUser)) 28 | } 29 | 30 | export async function remove (client: PoolClient, input: User): Promise { 31 | await client.query({ 32 | text: ` 33 | DELETE FROM loans WHERE user_id = $1 34 | `, 35 | values: [input.id] 36 | }) 37 | 38 | await client.query({ 39 | text: ` 40 | DELETE FROM users WHERE id = $1 41 | `, 42 | values: [input.id] 43 | }) 44 | } 45 | 46 | function rowToUser (row: { [key: string]: any }) { 47 | return { 48 | id: row.id, 49 | name: row.name, 50 | address: { 51 | line1: row.address_line1, 52 | line2: row.address_line2, 53 | postalCode: row.postal_code 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/adapters/postgres/util.ts: -------------------------------------------------------------------------------- 1 | // Some hastily-written utils for converting between our domain entities 2 | // and the way we encode them in Postgres. 3 | 4 | export function rowToObject (obj: {[key: string]: any}) { 5 | return Object.keys(obj).reduce( 6 | (acc, key) => ({ 7 | ...acc, 8 | [snakeToCamelCase(key)]: obj[key] 9 | }), 10 | {} 11 | ) 12 | } 13 | 14 | function snakeToCamelCase (input: string) { 15 | const [first, ...parts] = input.split('_') 16 | const titleCased = parts.map(s => { 17 | const [init, ...rest] = Array.from(s) 18 | return [init.toUpperCase(), ...rest].join('') 19 | }) 20 | return [first, ...titleCased].join('') 21 | } 22 | -------------------------------------------------------------------------------- /src/adapters/rabbitmq/index.ts: -------------------------------------------------------------------------------- 1 | import * as amqp from 'amqplib' 2 | import { Ctx } from '@lib' 3 | 4 | const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) 5 | 6 | export async function $adapter (): Promise { 7 | const connection = await attemptConnection() 8 | const channel = await connection.createChannel() 9 | 10 | await channel.assertQueue('messages') 11 | 12 | const dispatchEvent = async (eventName: string, content: object) => { 13 | const payload = JSON.stringify({ eventName, ...content }, null, 2) 14 | channel.sendToQueue('messages', Buffer.from(payload)) 15 | } 16 | 17 | const events: Ctx.EventsCtx = { 18 | onUserAdded: (event) => dispatchEvent('userAdded', event), 19 | onUserDeleted: (event) => dispatchEvent('userDeleted', event), 20 | onLoanMade: (event) => dispatchEvent('loanMade', event), 21 | onBookAdded: (event) => dispatchEvent('bookAdded', event), 22 | onBookRemoved: (event) => dispatchEvent('bookRemoved', event) 23 | } 24 | 25 | return async function adapter (op: Ctx.Operation) { 26 | return { 27 | op, 28 | ctx: { events } 29 | } 30 | } 31 | } 32 | 33 | async function attemptConnection (count: number = 6): Promise { 34 | try { 35 | const connection = await amqp.connect('amqp://guest:guest@rabbitmq:5672') 36 | console.log('Connected to AMQP') 37 | 38 | return connection 39 | 40 | } catch (err) { 41 | if (!count) { 42 | console.log('AMQP connection failed') 43 | throw err 44 | 45 | } else { 46 | console.log('Retrying AMQP connection in 5 seconds...') 47 | await wait(5000) 48 | return await attemptConnection(count - 1) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/apps/README.md: -------------------------------------------------------------------------------- 1 | Learn about apps [here](https://app.gitbook.com/@jbreckmckye/s/node-ts-architecture/putting-it-all-together/assembling-apps). -------------------------------------------------------------------------------- /src/apps/webapp/index.ts: -------------------------------------------------------------------------------- 1 | import { $adapter as $postgres } from '@adapters/postgres' 2 | import { $adapter as $rabbitmq } from '@adapters/rabbitmq' 3 | import { binding } from '@bindings/express' 4 | import { createLibrary, mergeAdapters } from '@lib' 5 | 6 | // An app is an executable and made up of three parts: 7 | // 8 | // 1. The LIBRARY - this represents entities and operations in the domain 9 | // (the business logic) 10 | // 11 | // 2. ADAPTERS - these are dependencies of the library injected into it at 12 | // runtime. They can include things like databases or HTTP services. 13 | // 14 | // 3. BINDINGS - these take a library plus adapters and bind it to events 15 | // coming from the world, e.g. HTTP requests or messages on an event bus 16 | 17 | async function makeApp () { 18 | // Note there is a naming convention in this project where '$foo' is a 19 | // function that itself returns a function 'foo' 20 | 21 | // Prepare the adapters 22 | const postgres = await $postgres() 23 | const rabbitmq = await $rabbitmq() 24 | 25 | // Combine the adapters 26 | const adapters = mergeAdapters( 27 | postgres, 28 | rabbitmq 29 | ) 30 | 31 | // Pass adapters into the library 32 | const library = createLibrary(adapters) 33 | 34 | // Bind incoming HTTP requests to our library 35 | binding(library) 36 | } 37 | 38 | makeApp() 39 | -------------------------------------------------------------------------------- /src/bindings/README.md: -------------------------------------------------------------------------------- 1 | Learn about bindings [here](https://app.gitbook.com/@jbreckmckye/s/node-ts-architecture/creating-bindings/what-are-bindings). -------------------------------------------------------------------------------- /src/bindings/express/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import bodyParser from 'body-parser' 3 | import { Library, Entities } from '@lib' 4 | import { route } from './route' 5 | 6 | export function binding (lib: Library) { 7 | const port = 3000 8 | const app = express() 9 | 10 | app.use(bodyParser.json()) 11 | 12 | app.get('/health', (req, res) => res.sendStatus(200)) 13 | 14 | app.post('/book', route({ 15 | fn: lib.book.add, 16 | validateInput: Entities.castBookInput, 17 | onSuccess: (res, result) => res.status(201).json(result) 18 | })) 19 | 20 | app.delete('/book/:bookID', route({ 21 | fn: lib.book.remove, 22 | validateInput: Entities.castUUID, 23 | getInput: (req, params) => (params as any).bookID, 24 | onSuccess: (res) => res.sendStatus(200) 25 | })) 26 | 27 | app.post('/loan', route({ 28 | fn: lib.loan.take, 29 | validateInput: Entities.castLoanInput, 30 | onSuccess: (res, result) => res.status(201).json(result) 31 | })) 32 | 33 | app.post('/return/:bookID', route({ 34 | fn: lib.loan.return, 35 | validateInput: Entities.castUUID, 36 | getInput: (req, params) => (params as any).bookID, 37 | onSuccess: (res) => res.sendStatus(200) 38 | })) 39 | 40 | app.post('/user', route({ 41 | fn: lib.user.add, 42 | validateInput: Entities.castUserInput, 43 | onSuccess: (res, result) => res.status(201).json(result) 44 | })) 45 | 46 | app.delete('/user/:userID', route({ 47 | fn: lib.user.remove, 48 | validateInput: Entities.castUUID, 49 | getInput: (req, params) => (params as any).userID, 50 | onSuccess: (res) => res.sendStatus(200) 51 | })) 52 | 53 | app.listen(port, () => { 54 | console.log('Example library app listening at port', port) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /src/bindings/express/route.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from 'express' 2 | 3 | export type RouteConfig = { 4 | fn: (input: T) => Promise, 5 | getInput?: (req: object, params: object) => T, 6 | validateInput: (input: unknown) => T, 7 | onSuccess: (res: Response, output: U) => void 8 | } 9 | 10 | export function route (config: RouteConfig) { 11 | const { fn, validateInput, onSuccess, getInput } = config 12 | 13 | return async (req: Request, res: Response, next: NextFunction) => { 14 | try { 15 | const input = getInput 16 | ? getInput(req.body, req.params) 17 | : req.body 18 | 19 | const validatedInput = await Promise.resolve(input) 20 | .then(validateInput) 21 | .catch(err => { 22 | res.sendStatus(400).json({ 23 | err: err.message 24 | }) 25 | throw err 26 | }) 27 | 28 | const output = await Promise.resolve(validatedInput) 29 | .then(fn) 30 | .catch(err => { 31 | if (err && err.invalidOperationErr) { 32 | res.status(422).json({ 33 | err: err.message 34 | }) 35 | } else { 36 | res.sendStatus(500) 37 | } 38 | throw err 39 | }) 40 | 41 | await onSuccess(res, output) 42 | 43 | } catch (err) { 44 | return next(err) 45 | } 46 | 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/lib/context/README.md: -------------------------------------------------------------------------------- 1 | Read about the context folder [here](https://jbreckmckye.gitbook.io/node-ts-architecture/step-by-step/context). -------------------------------------------------------------------------------- /src/lib/context/backend.ts: -------------------------------------------------------------------------------- 1 | import { UUID } from 'io-ts-types/lib/UUID' 2 | 3 | import { 4 | BookInput, 5 | Book, 6 | User, 7 | UserInput, 8 | Loan, LoanInput 9 | } from '../entities' 10 | 11 | export type BackendCtx = { 12 | bookStore: BookStore, 13 | loanStore: LoanStore, 14 | userStore: UserStore, 15 | } 16 | 17 | export type BookStore = { 18 | add: (b: BookInput) => Promise, 19 | remove: (i: Book) => Promise, 20 | find: (i: UUID) => Promise 21 | } 22 | 23 | export type UserStore = { 24 | add: (u: UserInput) => Promise, 25 | remove: (u: User) => Promise, 26 | find: (i: UUID) => Promise 27 | } 28 | 29 | export type LoanStore = { 30 | takeLoan: (l: LoanInput) => Promise, 31 | endLoan: (l: Loan) => Promise, 32 | getUserLoans: (u: User) => Promise, 33 | getLoan: (b: Book) => Promise 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/context/events.ts: -------------------------------------------------------------------------------- 1 | import * as Events from '../events' 2 | 3 | export type EventsCtx = { 4 | onUserAdded: (u: Events.UserAdded) => Promise, 5 | onUserDeleted: (u: Events.UserDeleted) => Promise, 6 | onLoanMade: (l: Events.LoanMade) => Promise, 7 | onBookAdded: (b: Events.BookAdded) => Promise, 8 | onBookRemoved: (b: Events.BookRemoved) => Promise 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/context/index.ts: -------------------------------------------------------------------------------- 1 | import { BackendCtx } from './backend' 2 | import { EventsCtx } from './events' 3 | 4 | export { 5 | BackendCtx, 6 | EventsCtx 7 | } 8 | 9 | export type Context = { 10 | backend: BackendCtx, 11 | events: EventsCtx 12 | } 13 | 14 | export type Operation = (c: Context, i: I) => Promise 15 | 16 | export type ContextAdapter = 17 | (op: Operation, params?: P) => Promise<{ ctx: Partial, op: Operation }> 18 | 19 | export function mergeAdapters (...args: ContextAdapter[]): ContextAdapter { 20 | const [first, ...rest] = args 21 | 22 | return async function (op, params) { 23 | const result = await first(op, params) 24 | 25 | if (rest.length === 0) { 26 | return result 27 | 28 | } else { 29 | const restResult = await (mergeAdapters(...rest)(result.op, params)) 30 | return { 31 | ctx: { 32 | ...result.ctx, 33 | ...restResult.ctx 34 | }, 35 | op: restResult.op 36 | } 37 | } 38 | } 39 | } 40 | 41 | export function wrapAdapter (adapter: CA, operation: Operation) { 42 | return async function (input: I, adapterParams?: any) { 43 | const { ctx, op } = await adapter(operation, adapterParams) 44 | return op(ctx as Context, input) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/entities/Book.ts: -------------------------------------------------------------------------------- 1 | import * as t from 'io-ts' 2 | import { UUID } from 'io-ts-types/lib/UUID' 3 | import { $cast } from './cast' 4 | 5 | // IO-TS Codecs 6 | // --------------------------------------------------------------- 7 | 8 | export const BookInput = t.exact(t.type({ 9 | name: t.string 10 | })) 11 | 12 | export const Book = t.exact( 13 | t.type({ 14 | id: UUID, 15 | name: t.string 16 | }) 17 | ) 18 | 19 | 20 | // Casts 21 | // --------------------------------------------------------------- 22 | 23 | export const castBook = $cast(Book) 24 | export const castBookInput = $cast(BookInput) 25 | 26 | 27 | // Static types 28 | // --------------------------------------------------------------- 29 | 30 | export type Book = t.TypeOf 31 | export type BookInput = t.TypeOf 32 | -------------------------------------------------------------------------------- /src/lib/entities/Loan.ts: -------------------------------------------------------------------------------- 1 | import * as t from 'io-ts' 2 | import { UUID } from 'io-ts-types/lib/UUID' 3 | 4 | import { $cast } from './cast' 5 | 6 | // IO-TS Codecs 7 | // --------------------------------------------------------------- 8 | 9 | export const LoanInput = t.exact( 10 | t.type({ 11 | userId: UUID, 12 | bookId: UUID 13 | }) 14 | ) 15 | 16 | export const Loan = t.exact( 17 | t.type({ 18 | id: UUID, 19 | userId: UUID, 20 | bookId: UUID, 21 | returned: t.boolean 22 | }) 23 | ) 24 | 25 | 26 | // Casts 27 | // --------------------------------------------------------------- 28 | 29 | export const castLoanInput = $cast(LoanInput) 30 | export const castLoan = $cast(Loan) 31 | 32 | 33 | // Static types 34 | // --------------------------------------------------------------- 35 | 36 | export type LoanInput = t.TypeOf 37 | export type Loan = t.TypeOf 38 | 39 | -------------------------------------------------------------------------------- /src/lib/entities/README.md: -------------------------------------------------------------------------------- 1 | Read about the entities folder here [entities](https://jbreckmckye.gitbook.io/node-ts-architecture/step-by-step/entities). -------------------------------------------------------------------------------- /src/lib/entities/User.ts: -------------------------------------------------------------------------------- 1 | import * as t from 'io-ts' 2 | import { UUID } from 'io-ts-types/lib/UUID' 3 | import { $cast } from './cast' 4 | 5 | const userFields = { 6 | name: t.string, 7 | address: t.type({ 8 | line1: t.string, 9 | line2: t.union([t.string, t.undefined]), 10 | postalCode: t.string 11 | }) 12 | } 13 | 14 | // IO-TS Codecs 15 | // --------------------------------------------------------------- 16 | 17 | export const UserInput = t.exact(t.type(userFields)) 18 | export const User = t.exact( 19 | t.type({ 20 | id: UUID, 21 | ...userFields 22 | }) 23 | ) 24 | 25 | 26 | // Casts 27 | // --------------------------------------------------------------- 28 | 29 | export const castUserInput = $cast(UserInput) 30 | export const castUser = $cast(User) 31 | 32 | 33 | // Static types 34 | // --------------------------------------------------------------- 35 | 36 | export type UserInput = t.TypeOf 37 | export type User = t.TypeOf 38 | -------------------------------------------------------------------------------- /src/lib/entities/cast.ts: -------------------------------------------------------------------------------- 1 | import { Decoder } from 'io-ts' 2 | import { reporter } from 'io-ts-reporters' 3 | import { UUID } from 'io-ts-types/lib/UUID' 4 | 5 | import { EntityDecodeError } from '@lib/errors' 6 | 7 | export function $cast (codec: Decoder) { 8 | return function cast (input: unknown): T { 9 | const result = codec.decode(input) 10 | 11 | if ('left' in result) { 12 | const err = reporter(result) 13 | throw new EntityDecodeError(err.join('\n')) 14 | } 15 | 16 | return result.right 17 | } 18 | } 19 | 20 | export const castUUID = $cast(UUID) 21 | -------------------------------------------------------------------------------- /src/lib/entities/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | BookInput, 3 | Book, 4 | castBookInput, 5 | castBook 6 | } from './Book' 7 | 8 | export { 9 | Loan, 10 | LoanInput, 11 | castLoanInput, 12 | castLoan 13 | } from './Loan' 14 | 15 | export { 16 | UserInput, 17 | User, 18 | castUser, 19 | castUserInput 20 | } from './User' 21 | 22 | export { 23 | castUUID 24 | } from './cast' 25 | -------------------------------------------------------------------------------- /src/lib/errors/README.md: -------------------------------------------------------------------------------- 1 | Read about the errors folder [here](https://jbreckmckye.gitbook.io/node-ts-architecture/step-by-step/errors). -------------------------------------------------------------------------------- /src/lib/errors/book.ts: -------------------------------------------------------------------------------- 1 | import { UUID } from 'io-ts-types/lib/UUID' 2 | 3 | export class BookDoesNotExist extends Error { 4 | public invalidOperationErr = true 5 | 6 | constructor(public id: UUID) { 7 | super(`Book with ID ${id} does not exist`) 8 | } 9 | } 10 | 11 | export class BookHasOutstandingLoan extends Error { 12 | public invalidOperationErr = true 13 | 14 | constructor(public id: UUID) { 15 | super(`Book with ID ${id} has an outstanding loan`) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/errors/entities.ts: -------------------------------------------------------------------------------- 1 | export class EntityDecodeError extends Error { 2 | constructor (failureMsg: string) { 3 | super(`Unable to decode entity as expected type. Mismatch was: ${failureMsg}`) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/errors/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | BookDoesNotExist, 3 | BookHasOutstandingLoan 4 | } from './book' 5 | 6 | export { 7 | BookWasNotLoaned 8 | } from './loan' 9 | 10 | export { 11 | UserDoesNotExist, 12 | UserHasOutstandingLoans 13 | } from './user' 14 | 15 | export { 16 | EntityDecodeError 17 | } from './entities' 18 | -------------------------------------------------------------------------------- /src/lib/errors/loan.ts: -------------------------------------------------------------------------------- 1 | import { UUID } from 'io-ts-types/lib/UUID' 2 | 3 | export class BookWasNotLoaned extends Error { 4 | public invalidOperationErr = true 5 | 6 | constructor(public bookId: UUID) { 7 | super(`Book with ID ${bookId} was not loaned`) 8 | } 9 | } 10 | 11 | export class BookAlreadyLoaned extends Error { 12 | public invalidOperationErr = true 13 | 14 | constructor(public bookId: UUID) { 15 | super(`Cannot borrow book with ID ${bookId}, as it is already loaned`) 16 | } 17 | } 18 | 19 | export class UserLoanLimitExceeded extends Error { 20 | public invalidOperationErr = true 21 | 22 | constructor(public userId: UUID) { 23 | super(`User with ID ${userId} has exceeded their loan limit`) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/errors/user.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../entities' 2 | import { UUID } from 'io-ts-types/lib/UUID' 3 | 4 | export class UserHasOutstandingLoans extends Error { 5 | public invalidOperationErr = true 6 | 7 | constructor(public user: User) { 8 | super(`User has outstanding loans`) 9 | } 10 | } 11 | 12 | export class UserDoesNotExist extends Error { 13 | public invalidOperationErr = true 14 | 15 | constructor(public id: UUID) { 16 | super(`User with ID ${id} does not exist`) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/events/Book.ts: -------------------------------------------------------------------------------- 1 | import { UUID } from 'io-ts-types/lib/UUID' 2 | 3 | export type BookAdded = { 4 | bookId: UUID, 5 | name: string 6 | } 7 | 8 | export type BookRemoved = { 9 | bookId: UUID 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/events/Loan.ts: -------------------------------------------------------------------------------- 1 | export type LoanMade = { 2 | bookName: string 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/events/README.md: -------------------------------------------------------------------------------- 1 | Read about the events folder [here](https://jbreckmckye.gitbook.io/node-ts-architecture/step-by-step/events). -------------------------------------------------------------------------------- /src/lib/events/User.ts: -------------------------------------------------------------------------------- 1 | import { UUID } from 'io-ts-types/lib/UUID' 2 | 3 | export type UserAdded = { 4 | userId: UUID 5 | } 6 | 7 | export type UserDeleted = { 8 | userId: UUID 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/events/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | BookAdded, 3 | BookRemoved 4 | } from './Book' 5 | 6 | export { 7 | UserAdded, 8 | UserDeleted 9 | } from './User' 10 | 11 | export { 12 | LoanMade 13 | } from './Loan' 14 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | import * as Operations from './operations' 2 | import * as Entities from './entities' 3 | import * as Errors from './errors' 4 | import * as Events from './events' 5 | import * as Ctx from './context' 6 | 7 | import { ContextAdapter, wrapAdapter, mergeAdapters } from './context' 8 | 9 | export { 10 | Ctx, 11 | Entities, 12 | Errors, 13 | Events, 14 | Operations, 15 | mergeAdapters 16 | } 17 | 18 | export type Library = ReturnType 19 | 20 | export function createLibrary (adapter: Adapter) { 21 | return { 22 | book: { 23 | add: wrapAdapter(adapter, Operations.addBook), 24 | remove: wrapAdapter(adapter, Operations.removeBook) 25 | }, 26 | loan: { 27 | take: wrapAdapter(adapter, Operations.loanBook), 28 | return: wrapAdapter(adapter, Operations.returnBook) 29 | }, 30 | user: { 31 | add: wrapAdapter(adapter, Operations.addUser), 32 | remove: wrapAdapter(adapter, Operations.removeUser) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/operations/README.md: -------------------------------------------------------------------------------- 1 | Read about the operations folder [here](https://jbreckmckye.gitbook.io/node-ts-architecture/step-by-step/operations). -------------------------------------------------------------------------------- /src/lib/operations/book.ts: -------------------------------------------------------------------------------- 1 | import { Book, BookInput } from '../entities' 2 | import { Context } from '../context' 3 | import { UUID } from 'io-ts-types' 4 | import { BookDoesNotExist, BookHasOutstandingLoan } from '@lib/errors' 5 | 6 | export async function addBook (ctx: Context, bookInput: BookInput): Promise { 7 | const { 8 | backend: { bookStore }, 9 | events 10 | } = ctx 11 | 12 | const book = await bookStore.add(bookInput) 13 | 14 | await events.onBookAdded({ 15 | bookId: book.id, 16 | name: book.name 17 | }) 18 | 19 | return book 20 | } 21 | 22 | export async function removeBook (ctx: Context, bookId: UUID): Promise { 23 | const { 24 | backend: { bookStore, loanStore }, 25 | events 26 | } = ctx 27 | 28 | const book = await bookStore.find(bookId) 29 | assertBook(book, bookId) 30 | 31 | const activeLoan = await loanStore.getLoan(book) 32 | if (activeLoan) { 33 | throw new BookHasOutstandingLoan(bookId) 34 | } 35 | 36 | await bookStore.remove(book) 37 | 38 | await events.onBookRemoved({ 39 | bookId: bookId 40 | }) 41 | } 42 | 43 | function assertBook (book: Book | null, id: UUID): asserts book is Book { 44 | if (!book) throw new BookDoesNotExist(id) 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/operations/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | addBook, 3 | removeBook 4 | } from './book' 5 | 6 | export { 7 | loanBook, 8 | returnBook 9 | } from './loan' 10 | 11 | export { 12 | addUser, 13 | removeUser 14 | } from './user' 15 | -------------------------------------------------------------------------------- /src/lib/operations/loan.ts: -------------------------------------------------------------------------------- 1 | import { UUID } from 'io-ts-types/lib/UUID' 2 | 3 | import { Context } from '../context' 4 | import { UserDoesNotExist, BookDoesNotExist, BookWasNotLoaned } from '../errors' 5 | import { Book, LoanInput, Loan, User } from '../entities' 6 | import { BookAlreadyLoaned, UserLoanLimitExceeded } from '@lib/errors/loan' 7 | 8 | 9 | export async function loanBook (ctx: Context, loanInput: LoanInput): Promise { 10 | const { 11 | backend: { userStore, bookStore, loanStore }, 12 | events 13 | } = ctx 14 | 15 | const user = await userStore.find(loanInput.userId) 16 | assertUser(user, loanInput.userId) 17 | 18 | const book = await bookStore.find(loanInput.bookId) 19 | assertBook(book, loanInput.bookId) 20 | 21 | const existingLoan = await loanStore.getLoan(book) 22 | if (existingLoan) { 23 | throw new BookAlreadyLoaned(loanInput.bookId) 24 | } 25 | 26 | const userLoans = await loanStore.getUserLoans(user) 27 | if (userLoans.length > 3) { 28 | throw new UserLoanLimitExceeded(loanInput.userId) 29 | } 30 | 31 | const loan = await loanStore.takeLoan({ 32 | bookId: book.id, 33 | userId: user.id 34 | }) 35 | 36 | await events.onLoanMade({ 37 | bookName: book.name 38 | }) 39 | 40 | return loan 41 | } 42 | 43 | 44 | export async function returnBook (ctx: Context, bookId: UUID): Promise { 45 | const { 46 | backend: { bookStore, loanStore } 47 | } = ctx 48 | 49 | const book = await bookStore.find(bookId) 50 | assertBook(book, bookId) 51 | 52 | const loan = await loanStore.getLoan(book) 53 | if (!loan) throw new BookWasNotLoaned(bookId) 54 | 55 | await loanStore.endLoan(loan) 56 | } 57 | 58 | 59 | function assertUser (user: User | null, id: UUID): asserts user is User { 60 | if (!user) throw new UserDoesNotExist(id) 61 | } 62 | 63 | function assertBook (book: Book | null, id: UUID): asserts book is Book { 64 | if (!book) throw new BookDoesNotExist(id) 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/operations/user.ts: -------------------------------------------------------------------------------- 1 | import { UUID } from 'io-ts-types/lib/UUID' 2 | import { User, UserInput } from '../entities' 3 | import { Context } from '../context' 4 | import { UserDoesNotExist, UserHasOutstandingLoans } from '../errors' 5 | 6 | export async function addUser (ctx: Context, userInput: UserInput): Promise { 7 | const { 8 | backend: { userStore }, 9 | events 10 | } = ctx 11 | 12 | const user = await userStore.add(userInput) 13 | 14 | await events.onUserAdded({ 15 | userId: user.id 16 | }) 17 | 18 | return user 19 | } 20 | 21 | 22 | export async function removeUser (ctx: Context, userId: UUID): Promise { 23 | const { 24 | backend: { loanStore, userStore }, 25 | events 26 | } = ctx 27 | 28 | const user = await userStore.find(userId) 29 | if (user === null) { 30 | throw new UserDoesNotExist(userId) 31 | } 32 | 33 | const activeLoans = await loanStore.getUserLoans(user) 34 | if (activeLoans.length) { 35 | throw new UserHasOutstandingLoans(user) 36 | } 37 | 38 | await userStore.remove(user) 39 | 40 | await events.onUserDeleted({ 41 | userId: user.id 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /test/lib/operations/book.unit.spec.ts: -------------------------------------------------------------------------------- 1 | import { addBook } from '@lib/operations' 2 | import { createMockCtx } from '@test/util/mockCtx' 3 | import { BookInput } from '@lib/entities' 4 | 5 | describe('The book operation', () => { 6 | let mockCtx: ReturnType 7 | 8 | const validInput: BookInput = { 9 | name: 'How to Cook for Forty Humans' 10 | } 11 | 12 | beforeEach(() => { 13 | mockCtx = createMockCtx() 14 | }) 15 | 16 | test('adds a book to the book repository', async () => { 17 | await addBook(mockCtx, validInput) 18 | expect(mockCtx.backend.bookStore.add).toHaveBeenCalled() 19 | }) 20 | 21 | test('emits an analytics event to our data warehouse', async () => { 22 | await addBook(mockCtx, validInput) 23 | expect(mockCtx.events.onBookAdded).toHaveBeenCalled() 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /test/util/mockCtx.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid' 2 | import { BackendCtx, BookStore, LoanStore, UserStore } from '@lib/context/backend' 3 | import { Context, EventsCtx } from '@lib/context' 4 | import { BookInput } from '@lib/entities' 5 | 6 | export function createMockCtx (): Context { 7 | const bookRepository: BookStore = { 8 | add: jest.fn().mockReturnValue({ id: uuid(), name: 'I am a Book'}), 9 | remove: jest.fn(), 10 | find: jest.fn() 11 | } 12 | 13 | const userRepository: UserStore = { 14 | add: jest.fn(), 15 | remove: jest.fn(), 16 | find: jest.fn() 17 | } 18 | 19 | const loanRepository: LoanStore = { 20 | takeLoan: jest.fn(), 21 | endLoan: jest.fn(), 22 | getLoan: jest.fn(), 23 | getUserLoans: jest.fn() 24 | } 25 | 26 | const backend: BackendCtx = { 27 | bookStore: bookRepository, 28 | userStore: userRepository, 29 | loanStore: loanRepository 30 | } 31 | 32 | const events: EventsCtx = { 33 | onUserAdded: jest.fn(), 34 | onUserDeleted: jest.fn(), 35 | onLoanMade: jest.fn(), 36 | onBookAdded: jest.fn(), 37 | onBookRemoved: jest.fn() 38 | } 39 | 40 | return { 41 | backend, 42 | events 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2018", 5 | "esModuleInterop": true, 6 | 7 | "noImplicitAny": true, 8 | "strictNullChecks": true, 9 | 10 | "baseUrl": "src", 11 | "paths": { 12 | "@adapters": ["adapters"], 13 | "@adapters/*": ["adapters/*"], 14 | "@bindings": ["bindings"], 15 | "@bindings/*": ["bindings/*"], 16 | "@lib": ["lib"], 17 | "@lib/*": ["lib/*"], 18 | "@test": ["../test"], 19 | "@test/*": ["../test/*"] 20 | } 21 | }, 22 | "exclude": [ 23 | "node_modules" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------