├── .gitignore ├── config ├── mongodb.js ├── index.js ├── pg.js └── mysql.js ├── db-dev ├── Dockerfile ├── mysql.dockerfile ├── schema.sql ├── init.js └── rs-init.sh ├── transfert.http ├── app ├── index.js ├── api │ ├── index.js │ └── transfert │ │ └── transfert.routes.js ├── app.js └── plugins │ └── di.js ├── lib ├── db │ ├── db.service.d.ts │ ├── sqlImpl │ │ ├── pool.d.ts │ │ ├── pg │ │ │ ├── client.js │ │ │ └── pool.js │ │ └── mysql │ │ │ ├── client.js │ │ │ └── pool.js │ ├── utils.js │ ├── db.service.js │ └── mongoImpl │ │ └── pool.js ├── utils.js └── di │ ├── container.js │ └── injector.js ├── modules └── bank-accounts │ ├── bank-accounts.service.js │ ├── bank-accounts.service.d.ts │ ├── bank-accounts-mongo-impl.js │ ├── bank-accounts.model.js │ └── bank-accounts-sql-impl.js ├── docker-compose.yml ├── .env ├── scripts └── print-balance.js ├── mongo-rs.yml ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | -------------------------------------------------------------------------------- /config/mongodb.js: -------------------------------------------------------------------------------- 1 | export default Object.freeze({ 2 | connectionURI: process.env.MONGO_URI, 3 | dbName: process.env.MONGO_DB, 4 | }); 5 | -------------------------------------------------------------------------------- /db-dev/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:latest 2 | 3 | RUN mkdir -p /docker-entrypoint-initdb.d 4 | 5 | COPY *.sql /docker-entrypoint-initdb.d 6 | -------------------------------------------------------------------------------- /db-dev/mysql.dockerfile: -------------------------------------------------------------------------------- 1 | FROM mysql:latest 2 | 3 | RUN mkdir -p /docker-entrypoint-initdb.d 4 | 5 | COPY *.sql /docker-entrypoint-initdb.d 6 | -------------------------------------------------------------------------------- /transfert.http: -------------------------------------------------------------------------------- 1 | POST http://localhost:3005/transferts/ 2 | Content-Type: application/json 3 | 4 | { 5 | "from": 1, 6 | "to": 2, 7 | "amount": 40 8 | } 9 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | import conf from '../config/index.js'; 2 | import { createApp } from './app.js'; 3 | 4 | const app = createApp(conf); 5 | app.listen({ port: 3005 }); 6 | -------------------------------------------------------------------------------- /lib/db/db.service.d.ts: -------------------------------------------------------------------------------- 1 | export interface ITransactionManager { 2 | withinTransaction( 3 | fn: ({ client }: { client: Client }) => Promise 4 | ): Result; 5 | } 6 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | import pg from './pg.js'; 2 | import mysql from './mysql.js'; 3 | import mongo from './mongodb.js'; 4 | 5 | export default Object.freeze({ 6 | pg, 7 | mysql, 8 | mongo, 9 | engine: process.env.DB_ENGINE, 10 | }); 11 | -------------------------------------------------------------------------------- /config/pg.js: -------------------------------------------------------------------------------- 1 | export default Object.freeze({ 2 | user: process.env.POSTGRES_USER, 3 | host: process.env.POSTGRES_HOST, 4 | database: process.env.POSTGRES_DB, 5 | password: process.env.POSTGRES_PASSWORD, 6 | port: process.env.POSTGRES_PORT, 7 | }); 8 | -------------------------------------------------------------------------------- /app/api/index.js: -------------------------------------------------------------------------------- 1 | import { transfertAPI } from './transfert/transfert.routes.js'; 2 | import { serviceRegistry } from '../plugins/di.js'; 3 | 4 | export const createAPI = async (instance, opts) => { 5 | instance.register(serviceRegistry, opts); 6 | instance.register(transfertAPI, { prefix: '/transferts' }); 7 | }; 8 | -------------------------------------------------------------------------------- /db-dev/schema.sql: -------------------------------------------------------------------------------- 1 | BEGIN; 2 | DROP TABLE IF EXISTS bank_accounts; 3 | 4 | CREATE TABLE bank_accounts( 5 | bank_account_id INTEGER PRIMARY KEY, 6 | balance INTEGER CHECK (balance >= 0) 7 | ); 8 | 9 | INSERT INTO bank_accounts(bank_account_id, balance) VALUES 10 | (1, 100), 11 | (2, 100); 12 | COMMIT; 13 | -------------------------------------------------------------------------------- /config/mysql.js: -------------------------------------------------------------------------------- 1 | export default Object.freeze({ 2 | host: process.env.MYSQL_HOST, 3 | user: process.env.MYSQL_USER, 4 | database: process.env.MYSQL_DATABASE, 5 | password: process.env.MYSQL_PASSWORD, 6 | port: process.env.MYSQL_PORT, 7 | waitForConnections: true, 8 | connectionLimit: 10, 9 | queueLimit: 0, 10 | }); 11 | -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | import fastify from 'fastify'; 2 | import sensible from 'fastify-sensible'; 3 | import { createAPI } from './api/index.js'; 4 | 5 | export const createApp = (options) => { 6 | const app = fastify({ 7 | logger: true, 8 | }); 9 | app.register(sensible); 10 | app.register(createAPI, options); 11 | return app; 12 | }; 13 | -------------------------------------------------------------------------------- /lib/db/sqlImpl/pool.d.ts: -------------------------------------------------------------------------------- 1 | import { SQLStatement } from 'sql-template-strings'; 2 | import { ITransactionManager } from '../db.service.js'; 3 | 4 | export interface ISQLClient { 5 | query(statement: SQLStatement): Promise>; 6 | one(statement: SQLStatement): Promise; 7 | } 8 | 9 | export interface ISQLPool extends ITransactionManager {} 10 | -------------------------------------------------------------------------------- /modules/bank-accounts/bank-accounts.service.js: -------------------------------------------------------------------------------- 1 | import createBankAccountMongo from './bank-accounts-mongo-impl.js'; 2 | import createBankAccountSql from './bank-accounts-sql-impl.js'; 3 | 4 | export default ({ conf, Db }) => { 5 | const { engine } = conf; 6 | return engine === 'mongo' 7 | ? createBankAccountMongo({ Db }) 8 | : createBankAccountSql({ Db }); 9 | }; 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | build: 4 | context: ./db-dev 5 | env_file: 6 | - ./.env 7 | ports: 8 | - "5432:5432" 9 | db-mysql: 10 | build: 11 | dockerfile: mysql.dockerfile 12 | context: ./db-dev 13 | env_file: 14 | - ./.env 15 | command: --default-authentication-plugin=mysql_native_password 16 | ports: 17 | - "3306:3306" 18 | -------------------------------------------------------------------------------- /modules/bank-accounts/bank-accounts.service.d.ts: -------------------------------------------------------------------------------- 1 | type BankAccount = { bankAccountId: number; balance: number }; 2 | 3 | export interface IBankAccountsService { 4 | findOne({ 5 | bankAccountId, 6 | }: { 7 | bankAccountId: number; 8 | }): Promise; 9 | updateBalance({ 10 | bankAccountId, 11 | balance, 12 | }: { 13 | bankAccountId: number; 14 | balance: number; 15 | }): Promise; 16 | } 17 | -------------------------------------------------------------------------------- /modules/bank-accounts/bank-accounts-mongo-impl.js: -------------------------------------------------------------------------------- 1 | export default ({ Db }) => { 2 | const client = Db.collection('bank_accounts'); 3 | return { 4 | findOne: async ({ bankAccountId }) => client.findOne({ bankAccountId }), 5 | updateBalance: ({ bankAccountId, balance }) => 6 | client.updateOne( 7 | { 8 | bankAccountId, 9 | }, 10 | { 11 | $set: { balance }, 12 | } 13 | ), 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | POSTGRES_HOST=localhost 2 | POSTGRES_PORT=5432 3 | POSTGRES_DB=archi-example 4 | POSTGRES_USER=docker 5 | POSTGRES_PASSWORD=docker 6 | MYSQL_HOST=localhost 7 | MYSQL_PORT=3306 8 | MYSQL_DATABASE=archi-example 9 | MYSQL_USER=docker 10 | MYSQL_PASSWORD=docker 11 | MYSQL_ROOT_PASSWORD=docker 12 | MONGO_INITDB_DATABASE=archi-example 13 | MONGO_URI=mongodb://localhost:27017,localhost:27018,localhost:27019/?replicaSet=dbrs 14 | MONGO_DB=archi-example 15 | DB_ENGINE=pg 16 | -------------------------------------------------------------------------------- /lib/db/utils.js: -------------------------------------------------------------------------------- 1 | import { valueFn } from '../utils.js'; 2 | 3 | export const bindToInjectables = (factory) => (injectables) => { 4 | const Db = factory(injectables); 5 | return { 6 | ...Db, 7 | withinTransaction: (fn) => { 8 | return Db.withinTransaction(({ client }) => { 9 | return fn( 10 | injectables.inject({ 11 | Db: bindToInjectables(valueFn(client)), 12 | }) 13 | ); 14 | }); 15 | }, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | export const compose = (fns) => (arg) => fns.reduceRight((x, f) => f(x), arg); 2 | export const composeP = (fns) => (arg) => 3 | fns.reduceRight((x, f) => x.then(f), Promise.resolve(arg)); 4 | export const head = (array) => array.at(0); 5 | export const map = (fn) => (functor) => functor.map(fn); 6 | export const mapKeys = (fn) => (target) => 7 | Object.fromEntries( 8 | Object.entries(target).map(([key, val]) => [fn(key), val]) 9 | ); 10 | export const valueFn = (val) => () => val; 11 | -------------------------------------------------------------------------------- /modules/bank-accounts/bank-accounts.model.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | 3 | export const credit = ({ account, amount }) => ({ 4 | ...account, 5 | balance: 6 | assert.ok(amount > 0, 'amount must be positive') || 7 | account.balance + amount, 8 | }); 9 | 10 | export const debit = ({ account, amount }) => ({ 11 | ...account, 12 | balance: 13 | assert.ok(amount > 0, 'amount must be positive') || 14 | account.balance - amount, 15 | }); 16 | 17 | export const transfert = ({ from, to, amount }) => ({ 18 | from: debit({ account: from, amount }), 19 | to: credit({ account: to, amount }), 20 | }); 21 | -------------------------------------------------------------------------------- /lib/db/sqlImpl/pg/client.js: -------------------------------------------------------------------------------- 1 | import camelcase from 'camelcase'; 2 | import { compose, composeP, head, map, mapKeys } from '../../../utils.js'; 3 | 4 | const normalizeKeys = mapKeys(camelcase); 5 | 6 | // private 7 | export const createClient = ({ client }) => { 8 | const query = (...args) => client.query(...args); 9 | const formatRows = compose([map(normalizeKeys), ({ rows }) => rows]); 10 | const clientService = { 11 | query: composeP([formatRows, query]), 12 | one: composeP([compose([head, formatRows]), query]), 13 | withinTransaction: (fn) => fn({ client: clientService }), 14 | }; 15 | 16 | return clientService; 17 | }; 18 | -------------------------------------------------------------------------------- /db-dev/init.js: -------------------------------------------------------------------------------- 1 | db = new Mongo('localhost:27017').getDB('archi-example'); 2 | db.createCollection('bank_accounts', { 3 | validator: { 4 | $jsonSchema: { 5 | bsonType: 'object', 6 | properties: { 7 | bankAccountId: { 8 | bsonType: 'int', 9 | }, 10 | balance: { 11 | bsonType: 'int', 12 | minimum: 0, 13 | }, 14 | }, 15 | required: ['bankAccountId', 'balance'], 16 | }, 17 | }, 18 | }); 19 | 20 | db.bank_accounts.insertMany([ 21 | { bankAccountId: NumberInt(1), balance: NumberInt(100) }, 22 | { bankAccountId: NumberInt(2), balance: NumberInt(100) }, 23 | ]); 24 | -------------------------------------------------------------------------------- /app/plugins/di.js: -------------------------------------------------------------------------------- 1 | import fp from 'fastify-plugin'; 2 | import { createContainer } from '../../lib/di/container.js'; 3 | 4 | export const serviceRegistry = fp(async (instance, opts) => { 5 | const { injectableGlob = '**/*.service.js', ...conf } = opts; 6 | const abortController = new AbortController(); 7 | const { signal } = abortController; 8 | const getInjectables = await createContainer({ injectableGlob }); 9 | instance.addHook('onClose', async (instance) => { 10 | abortController.abort(); 11 | }); 12 | const services = getInjectables({ conf, signal }); 13 | for (const [token, service] of Object.entries(services)) { 14 | instance.decorate(token, service); 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /modules/bank-accounts/bank-accounts-sql-impl.js: -------------------------------------------------------------------------------- 1 | import SQL from 'sql-template-strings'; 2 | import { compose } from '../../lib/utils.js'; 3 | 4 | export default ({ Db }) => { 5 | return { 6 | findOne: compose([Db.one, findBankAccountQuery]), 7 | updateBalance: compose([Db.one, updateBalanceQuery]), 8 | }; 9 | }; 10 | 11 | const findBankAccountQuery = ({ bankAccountId }) => SQL` 12 | SELECT 13 | * 14 | FROM 15 | bank_accounts 16 | WHERE 17 | bank_account_id= ${bankAccountId} 18 | `; 19 | 20 | const updateBalanceQuery = ({ bankAccountId, balance }) => SQL` 21 | UPDATE 22 | bank_accounts 23 | SET 24 | balance=${balance} 25 | WHERE 26 | bank_account_id=${bankAccountId} 27 | ;`; 28 | -------------------------------------------------------------------------------- /db-dev/rs-init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DELAY=20 4 | 5 | mongo < { 5 | const bankAccountIds = process.argv.slice(2); 6 | if (!bankAccountIds.length) { 7 | console.warn('no bank account id provided'); 8 | return; 9 | } 10 | const abortController = new AbortController(); 11 | const { signal } = abortController; 12 | 13 | try { 14 | const resolve = await createContainer({ 15 | injectableGlob: '**/*.service.js', 16 | }); 17 | const { BankAccounts } = resolve({ conf, signal }); 18 | 19 | for (const bankAccountId of bankAccountIds) { 20 | console.log( 21 | await BankAccounts.findOne({ bankAccountId: Number(bankAccountId) }) 22 | ); 23 | } 24 | } finally { 25 | abortController.abort(); 26 | } 27 | })(); 28 | -------------------------------------------------------------------------------- /lib/db/sqlImpl/mysql/client.js: -------------------------------------------------------------------------------- 1 | import camelcase from 'camelcase'; 2 | import { compose, composeP, head, map, mapKeys } from '../../../utils.js'; 3 | 4 | const normalizeKeys = mapKeys(camelcase); 5 | 6 | // private 7 | export const createClient = ({ client }) => { 8 | const query = (...args) => { 9 | return new Promise((resolve, reject) => { 10 | client.query(...args, (err, data) => { 11 | if (err) { 12 | reject(err); 13 | } 14 | resolve(Array.isArray(data) ? data : []); 15 | }); 16 | }); 17 | }; 18 | 19 | const formatRows = map(normalizeKeys); 20 | 21 | const clientService = { 22 | query: composeP([formatRows, query]), 23 | one: composeP([compose([head, formatRows]), query]), 24 | withinTransaction: (fn) => fn({ client: clientService }), 25 | }; 26 | 27 | return clientService; 28 | }; 29 | -------------------------------------------------------------------------------- /lib/di/container.js: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path'; 2 | import { cwd } from 'node:process'; 3 | import { pathToFileURL } from 'node:url'; 4 | import { globby } from 'globby'; 5 | import pascalCase from 'uppercamelcase'; 6 | import { createInjector } from './injector.js'; 7 | 8 | export const createContainer = async ({ 9 | injectableGlob = '**/*.service.js', 10 | }) => { 11 | const servicesFiles = await globby([injectableGlob]); 12 | const injectables = await Promise.all( 13 | servicesFiles.map((path) => Promise.all([getKey(path), getFactory(path)])) 14 | ); 15 | return createInjector(Object.fromEntries(injectables)); 16 | }; 17 | 18 | const getKey = (path) => pascalCase(path.split('/').at(-1).split('.').at(0)); 19 | const getFactory = async (path) => { 20 | const filePath = resolve(cwd(), path); 21 | return (await import(pathToFileURL(filePath))).default; 22 | }; 23 | -------------------------------------------------------------------------------- /lib/db/db.service.js: -------------------------------------------------------------------------------- 1 | import { createConnectionPool as createPGClient } from './sqlImpl/pg/pool.js'; 2 | import { createConnectionPool as createMYSQLClient } from './sqlImpl/mysql/pool.js'; 3 | import { createConnectionPool as createMongoClient } from './mongoImpl/pool.js'; 4 | import { bindToInjectables } from './utils.js'; 5 | import { compose } from '../utils.js'; 6 | import { singleton } from '../di/injector.js'; 7 | 8 | const dbClientFactory = ({ conf, signal }) => { 9 | const { engine = 'pg', pg, mysql, mongo } = conf; 10 | switch (engine) { 11 | case 'mongo': 12 | return createMongoClient({ ...mongo, signal }); 13 | case 'mysql': 14 | return createMYSQLClient({ ...mysql, signal }); 15 | default: 16 | return createPGClient({ ...pg, signal }); 17 | } 18 | }; 19 | 20 | const wrap = compose([singleton, bindToInjectables]); 21 | 22 | export default wrap(dbClientFactory); 23 | -------------------------------------------------------------------------------- /lib/db/sqlImpl/pg/pool.js: -------------------------------------------------------------------------------- 1 | import pg from 'pg'; 2 | import { createClient } from './client.js'; 3 | 4 | export const createConnectionPool = ({ signal, ...conf }) => { 5 | const pool = new pg.Pool(conf); 6 | const client = createClient({ client: pool }); 7 | 8 | signal.addEventListener( 9 | 'abort', 10 | () => { 11 | pool.end(); 12 | }, 13 | { once: true } 14 | ); 15 | 16 | return { 17 | ...client, 18 | async withinTransaction(fn) { 19 | const client = await pool.connect(); 20 | try { 21 | await client.query('BEGIN'); 22 | const result = await fn({ client: createClient({ client }) }); 23 | await client.query('COMMIT'); 24 | return result; 25 | } catch (e) { 26 | await client.query('ROLLBACK'); 27 | throw e; 28 | } finally { 29 | client.release(); 30 | } 31 | }, 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /mongo-rs.yml: -------------------------------------------------------------------------------- 1 | services: 2 | mongo1: 3 | image: mongo 4 | container_name: mongo1 5 | volumes: 6 | - ./db-dev/rs-init.sh:/scripts/rs-init.sh 7 | - ./db-dev/init.js:/scripts/init.js 8 | ports: 9 | - 27017:27017 10 | env_file: 11 | - ./.env 12 | depends_on: 13 | - mongo2 14 | - mongo3 15 | links: 16 | - mongo2 17 | - mongo3 18 | restart: always 19 | entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "dbrs" ] 20 | 21 | mongo2: 22 | image: mongo 23 | container_name: mongo2 24 | env_file: 25 | - ./.env 26 | ports: 27 | - 27018:27017 28 | restart: always 29 | entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "dbrs" ] 30 | mongo3: 31 | image: mongo 32 | container_name: mongo3 33 | ports: 34 | - 27019:27017 35 | env_file: 36 | - ./.env 37 | restart: always 38 | entrypoint: [ "/usr/bin/mongod", "--bind_ip_all", "--replSet", "dbrs" ] 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "archi-example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "type": "module", 6 | "scripts": { 7 | "start": "env $(xargs < .env) node app/index.js", 8 | "check-balance": "env $(xargs < .env) node scripts/print-balance.js", 9 | "dev": "env $(xargs < .env) nodemon app/index.js | pino-pretty", 10 | "format": "prettier -w .", 11 | "test": "pta" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "camelcase": "~6.3.0", 17 | "fastify": "~4.0.3", 18 | "fastify-plugin": "~3.0.1", 19 | "fastify-sensible": "~3.1.2", 20 | "globby": "~13.1.1", 21 | "mongodb": "~4.7.0", 22 | "mysql": "~2.18.1", 23 | "pg": "~8.7.3", 24 | "sql-template-strings": "~2.2.2", 25 | "uppercamelcase": "~3.0.0" 26 | }, 27 | "devDependencies": { 28 | "nodemon": "~2.0.15", 29 | "pino-pretty": "~7.6.1", 30 | "prettier": "~2.6.2", 31 | "pta": "~1.0.2" 32 | }, 33 | "prettier": { 34 | "singleQuote": true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/db/sqlImpl/mysql/pool.js: -------------------------------------------------------------------------------- 1 | import { createPool } from 'mysql'; 2 | import { createClient } from './client.js'; 3 | 4 | export const createConnectionPool = ({ signal, ...conf }) => { 5 | const pool = createPool(conf); 6 | 7 | signal.addEventListener( 8 | 'abort', 9 | () => { 10 | pool.end(); 11 | }, 12 | { once: true } 13 | ); 14 | 15 | return { 16 | ...createClient({ client: pool }), 17 | async withinTransaction(fn) { 18 | const client = await new Promise((resolve, reject) => { 19 | pool.getConnection((err, connection) => { 20 | if (err) { 21 | reject(err); 22 | } else { 23 | resolve(connection); 24 | } 25 | }); 26 | }); 27 | try { 28 | await client.query('BEGIN'); 29 | const result = await fn({ client: createClient({ client }) }); 30 | await client.query('COMMIT'); 31 | return result; 32 | } catch (e) { 33 | await client.query('ROLLBACK'); 34 | throw e; 35 | } finally { 36 | client.release(); 37 | } 38 | }, 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /app/api/transfert/transfert.routes.js: -------------------------------------------------------------------------------- 1 | import { transfert } from '../../../modules/bank-accounts/bank-accounts.model.js'; 2 | 3 | export const transfertAPI = async (instance) => { 4 | const { Db } = instance; 5 | 6 | instance.route({ 7 | method: 'POST', 8 | url: '/', 9 | schema: { 10 | body: { 11 | type: 'object', 12 | properties: { 13 | from: { type: 'integer' }, 14 | to: { type: 'integer' }, 15 | amount: { type: 'integer', exclusiveMinimum: 0 }, 16 | }, 17 | required: ['from', 'to', 'amount'], 18 | }, 19 | }, 20 | async handler(req, res) { 21 | await Db.withinTransaction(async ({ BankAccounts }) => { 22 | const [from, to] = await Promise.all([ 23 | BankAccounts.findOne({ 24 | bankAccountId: req.body.from, 25 | }), 26 | BankAccounts.findOne({ 27 | bankAccountId: req.body.to, 28 | }), 29 | ]); 30 | instance.assert(from && to, 422); 31 | const { from: newFrom, to: newTo } = transfert({ 32 | from, 33 | to, 34 | amount: req.body.amount, 35 | }); 36 | await BankAccounts.updateBalance(newTo); 37 | await BankAccounts.updateBalance(newFrom); 38 | }); 39 | 40 | res.status(201); 41 | }, 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /lib/di/injector.js: -------------------------------------------------------------------------------- 1 | const valueFn = (val) => () => val; 2 | const mapValues = (mapFn) => (source) => 3 | Object.fromEntries( 4 | [ 5 | ...Object.getOwnPropertyNames(source), 6 | ...Object.getOwnPropertySymbols(source), 7 | ].map((key) => [key, mapFn(source[key])]) 8 | ); 9 | 10 | export const createInjector = (serviceFactoryMap) => 11 | function inject(args = {}) { 12 | const target = {}; 13 | const deps = new Proxy(target, { 14 | get(target, prop, receiver) { 15 | if (!(prop in target)) { 16 | throw new Error(`could not resolve factory '${prop.toString()}'`); 17 | } 18 | return Reflect.get(target, prop, receiver); 19 | }, 20 | }); 21 | 22 | const propertyDescriptors = mapValues(createPropertyDescriptor); 23 | 24 | Object.defineProperties( 25 | target, 26 | propertyDescriptors({ 27 | ...serviceFactoryMap, 28 | inject: valueFn((subargs = {}) => 29 | inject({ 30 | ...args, 31 | ...subargs, 32 | }) 33 | ), 34 | ...args, 35 | }) 36 | ); 37 | 38 | return deps; 39 | 40 | function createPropertyDescriptor(factory) { 41 | const actualFactory = 42 | typeof factory === 'function' ? factory : valueFn(factory); 43 | return { 44 | get() { 45 | return actualFactory(deps); 46 | }, 47 | enumerable: true, 48 | }; 49 | } 50 | }; 51 | 52 | export const singleton = (factory) => { 53 | let value; 54 | return (deps) => { 55 | if (value) { 56 | return value; 57 | } 58 | 59 | return (value = factory(deps)); 60 | }; 61 | }; 62 | -------------------------------------------------------------------------------- /lib/db/mongoImpl/pool.js: -------------------------------------------------------------------------------- 1 | import { MongoClient } from 'mongodb'; 2 | import { composeP } from '../../utils.js'; 3 | 4 | export const createConnectionPool = ({ signal, ...conf }) => { 5 | let db; 6 | const { connectionURI, dbName } = conf; 7 | const client = new MongoClient(connectionURI); 8 | signal.addEventListener( 9 | 'abort', 10 | () => { 11 | client.close(); 12 | }, 13 | { once: true } 14 | ); 15 | 16 | return { 17 | collection(name, opts) { 18 | return createCollectionClient(name, { client }); 19 | }, 20 | async withinTransaction(fn) { 21 | let transactionResult; 22 | await getDb(); 23 | await client.withSession(async (session) => { 24 | await session.withTransaction(async (session) => { 25 | const sessionBoundClient = { 26 | collection(collection, opts = {}) { 27 | return createCollectionClient(collection, { 28 | ...opts, 29 | session, 30 | }); 31 | }, 32 | withinTransaction(fn) { 33 | return fn({ client }); 34 | }, 35 | }; 36 | transactionResult = await fn({ 37 | client: sessionBoundClient, 38 | }); 39 | }); 40 | }); 41 | return transactionResult; 42 | }, 43 | }; 44 | 45 | function createCollectionClient(collection, opts = {}) { 46 | const { session } = opts; 47 | const getClient = composeP([(db) => db.collection(collection), getDb]); 48 | 49 | return { 50 | async findOne(filter, opts) { 51 | const passedOpts = opts || {}; 52 | return getClient().then((client) => 53 | client.findOne(filter, { 54 | session, 55 | ...passedOpts, 56 | }) 57 | ); 58 | }, 59 | async updateOne(filter, update, opts) { 60 | const passedOpts = opts || {}; 61 | return getClient().then((client) => 62 | client.updateOne(filter, update, { 63 | session, 64 | ...passedOpts, 65 | }) 66 | ); 67 | }, 68 | }; 69 | } 70 | 71 | async function getDb() { 72 | if (db) { 73 | return db; 74 | } 75 | console.log('connecting'); 76 | await client.connect(); 77 | console.log('connected'); 78 | 79 | return (db = client.db(dbName)); 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dependency Injection example 2 | 3 | This is the application of the [following article](https://tech.indy.fr/2022/07/21/lets-build-a-di-system-in-javascript-using-meta-programming/) 4 | 5 | It uses the DI container defined in the article. An injectable (service) registers itself to the manifest using a convention: 6 | any file with the ``.service.js`` extension will be injectable. For example a file called ``bank-accounts.service.js`` will be injectable under the token ``BankAccounts``. 7 | 8 | The DI container is used in the context of a web server (built with the [Fastify framework](https://www.fastify.io/)) with one route which simulates a money transfert between two accounts. Once you have started the server you can run the following curl command: 9 | 10 | ```shell 11 | curl -X POST --location "http://localhost:3005/transferts/" \ 12 | -H "Content-Type: application/json" \ 13 | -d "{ 14 | \"from\": 1, 15 | \"to\": 2, 16 | \"amount\": 30 17 | }" 18 | ``` 19 | 20 | This will move 30(euros) from bank account 1 to bank account 2. 21 | The action is done within an atomic transaction, and there is a constraint over the balance field: if the balance of the debited account get lower than zero the transaction is aborted. 22 | 23 | The DI container does not have to be used with any particular framework. To show the point there is a script which uses the DI container and let you check the balance of any bank account. For example, you can run: 24 | 25 | ``npm run check-balance -- 1 2`` 26 | 27 | and you will see the balance for account 1 and account 2 28 | 29 | ## Getting started 30 | 31 | ### databases 32 | 33 | The beauty of DI is that it allows us to swap concrete implementations. In this case it gives the ability to change entirely the DB engine without touching the "business" code: the ```.models.js``` files and, the route handlers and scripts ("usecases"). 34 | 35 | We provided various implementations of the abstract interfaces: for MySQL and PostgresSQL (the only difference is the low level DB pool impl), and for mongodb (with a different bankAccounts service impl and a different db pool impl ) 36 | 37 | To toggle a particular engine: use the ``DB_ENGINE`` en variable (mongo, mysql or pg (default)) 38 | 39 | #### SQL databases 40 | 41 | You can start un local dev database with docker-compose: 42 | 43 | ``docker-compose up -d db`` 44 | 45 | This will start a postgres container and a mysql container with the schema already set and a data seed (two bank accounts with an initial balance of 100 euros) 46 | 47 | Note for mysql: you might get an authentication error. In this case 48 | 49 | 1. connect to the database: ``docker-compose exec db-mysql mysql -u root -p`` 50 | 2. fill the password ("docker" if you kept the same .env) 51 | 3. run the following command once you are connected to the database: ``ALTER USER 'root'@'%' IDENTIFIED WITH mysql_native_password BY 'password';`` 52 | 4. test the connection with the check-balance script: ``npm run check-balance -- 1 2`` 53 | 54 | 55 | 56 | #### Mongo database 57 | 58 | In order to support "transactions", the mongo set up needs to support replica sets. 59 | 60 | 1. make sure db-dev/rs-init.sh and db-dev/init.js are executable (``chmod +x path/to/file``) as there will be in a shared volume 61 | 2. start the mongo stack ``docker-compose -f mongo-rs.yml up`` 62 | 3. once you see in the logs that the first node is waiting for the replica set to be initialized, you can run ``docker exec mongo1 /scripts/rs-init.sh`` 63 | 64 | That's it: you will have a mongo cluster with the ``bank_accounts`` collection created and seeded. Note there are validation constraints as for the SQL databases setups 65 | 66 | ### server 67 | 68 | simply run ``npm dev`` 69 | 70 | You should now be able to send request with the aforementioned curl command 71 | 72 | ### script 73 | 74 | If you want to check the balance of account 1 and account 2: 75 | 76 | ``npm run check-balance -- 1 2`` 77 | 78 | ## overall architecture 79 | 80 | ```mermaid 81 | graph TD 82 | subgraph business [business layer] 83 | usecases -. import .-> models 84 | end 85 | ITransactionManager -- injected --> usecases 86 | IBankAccountsService -- injected --> usecases 87 | DB[DB Driver] -- injected --> IBankAccountsService 88 | ``` 89 | 90 | The business layer should reamain agnostic of database details: 91 | 1. It can import models: pure fonctions which compute the various entity states 92 | 2. It get injected an implementation of the high level IBankAccounts interface and an implementation of the ITransactionManager interface 93 | 94 | Note the "usecases" are implemented within the route handlers and the scripts themselve. Sometimes it is better not to mix the HTTP related concepts with the actual business usecases: in this case you should add an extra layer: 95 | 96 | ```mermaid 97 | graph TD 98 | ITransactionManager -- injected --> usecases 99 | IBankAccountService -- injected --> usecases 100 | DB[DB Driver] -- injected --> IBankAccountService 101 | subgraph business [business layer] 102 | usecases-. import .-> models 103 | end 104 | subgraph consumers 105 | usecases -- injected --> requests[request handlers] 106 | usecases -- injected --> scripts 107 | end 108 | ``` 109 | 110 | However, as it adds an extra layer, you need to map the business errors to http codes etc, improving the separation of conerns but at the price of a more complictated "plumbing" 111 | 112 | ### IBankAccounts 113 | 114 | A high level API to update the bank account resources: 115 | 116 | ```Typescript 117 | type BankAccount = { bankAccountId: number; balance: number }; 118 | 119 | export interface IBankAccountsService { 120 | findOne({ 121 | bankAccountId, 122 | }: { 123 | bankAccountId: number; 124 | }): Promise; 125 | updateBalance({ 126 | bankAccountId, 127 | balance, 128 | }: { 129 | bankAccountId: number; 130 | balance: number; 131 | }): Promise; 132 | } 133 | ``` 134 | 135 | Note: there is no database details in the interface 136 | 137 | ### ITransactionManager 138 | 139 | ```Typescript 140 | export interface ITransactionManager { 141 | withinTransaction( 142 | fn: ({ client }: { client: Client }) => Promise 143 | ): Result; 144 | } 145 | ``` 146 | 147 | Encapsulate the "ACID" concepts sometimes required for a business: it does not have to be tied to storage DB, but in practice it often relies on the storage database technology (In this repository for instance, the DB clients implement this interface without adding an extra indirection point: we could have an abstract TransactionManager.service.js which itselfs depends on a DB client) 148 | --------------------------------------------------------------------------------