├── src ├── modules │ ├── README.md │ └── bank-account │ │ ├── index.ts │ │ ├── bank-account.repository.ts │ │ ├── bank-account.schema.ts │ │ └── transfer-money.command.ts ├── framework │ ├── README.md │ ├── utils.js │ ├── db.d.ts │ ├── command.d.ts │ ├── db.js │ └── command.js └── index.ts ├── .env ├── .gitignore ├── setup-db.sh ├── docker └── docker-entrypoint-initdb.d │ └── init.sql ├── tsconfig.json ├── package.json ├── README.md └── LICENSE /src/modules/README.md: -------------------------------------------------------------------------------- 1 | The code related to the product. What your amazing software does 2 | -------------------------------------------------------------------------------- /src/framework/README.md: -------------------------------------------------------------------------------- 1 | The code related to the framework itself. You can see it as a library you would install in your project. 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | POSTGRES_HOST=localhost 2 | POSTGRES_PORT=5432 3 | POSTGRES_USER=postgres 4 | POSTGRES_PASSWORD=postgres 5 | POSTGRES_DB=test_app 6 | POSTGRES_INITDB_ARGS= -------------------------------------------------------------------------------- /src/framework/utils.js: -------------------------------------------------------------------------------- 1 | export const mapValues = (obj, mapFn) => 2 | Object.fromEntries( 3 | Object.entries(obj).map(([key, value]) => [key, mapFn(value, key), value]), 4 | ); 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /tmp 2 | /out-tsc 3 | 4 | /node_modules 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | /.pnp 9 | .pnp.js 10 | 11 | .vscode/* 12 | .idea 13 | .DS_Store 14 | dist 15 | -------------------------------------------------------------------------------- /setup-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker run -d --rm \ 4 | --name local_test \ 5 | --env-file .env \ 6 | -p 5432:5432 \ 7 | -v "$PWD/docker/docker-entrypoint-initdb.d":/docker-entrypoint-initdb.d \ 8 | postgres -------------------------------------------------------------------------------- /docker/docker-entrypoint-initdb.d/init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS bank_accounts ( 2 | bank_account_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, 3 | balance INTEGER NOT NULL CONSTRAINT positive_balance CHECK (balance > 0) 4 | ); 5 | 6 | INSERT INTO bank_accounts (balance) 7 | VALUES (10000), 8 | (10000); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "CommonJS", 5 | "lib": ["ESNext"], 6 | "skipDefaultLibCheck": true, 7 | "skipLibCheck": true, 8 | "outDir": "dist", 9 | "allowJs": true, 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "moduleDetection": "force", 13 | "isolatedModules": true 14 | }, 15 | "include": ["src"] 16 | } 17 | -------------------------------------------------------------------------------- /src/modules/bank-account/index.ts: -------------------------------------------------------------------------------- 1 | import { defineCommands } from '../../framework/command'; 2 | import { bankAccountSchema } from './bank-account.schema'; 3 | import { createTransferMoneyCommand } from './transfer-money.command'; 4 | import { createBankAccountRepository } from './bank-account.repository'; 5 | 6 | export const createBankAccountCommands = defineCommands({ 7 | schema: bankAccountSchema, 8 | injectables: { 9 | bankAccountService: createBankAccountRepository, 10 | }, 11 | commands: { 12 | transferMoney: createTransferMoneyCommand, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "db-transaction-test", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "dev": "tsc --watch", 8 | "build": "tsc", 9 | "start": "node --env-file=.env dist/index.js" 10 | }, 11 | "dependencies": { 12 | "ajv": "^8.17.1", 13 | "dismoi": "^0.3.5", 14 | "pg": "^8.13.1", 15 | "sql-template-strings": "^2.2.2" 16 | }, 17 | "devDependencies": { 18 | "@types/pg": "^8.11.10", 19 | "json-schema-to-ts": "^3.1.1", 20 | "prettier": "^3.4.2", 21 | "typescript": "^5.6.2", 22 | "zora": "^6.0.0" 23 | }, 24 | "prettier": { 25 | "singleQuote": true 26 | }, 27 | "private": true 28 | } 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # backend-framework 2 | Code related to some of the explorations on my blog 3 | - [separation of concerns](https://lorenzofox.dev/posts/separation-of-concerns/) 4 | - [schema first design](https://lorenzofox.dev/posts/schema-first-design/) 5 | 6 | ## Installation 7 | 8 | With [Nodejs](https://nodejs.org/en) (version > 22): 9 | ```shell 10 | npm ci 11 | ``` 12 | 13 | ## start db 14 | 15 | Assuming you have Docker installed: 16 | ```shell 17 | source setup-db.sh 18 | ``` 19 | 20 | ## usage 21 | 22 | You can then run dev command 23 | ```shell 24 | npm run dev 25 | ``` 26 | 27 | change what you want (in the index file for example) and run the dist script: 28 | ```shell 29 | npm start 30 | ``` 31 | ## Discussion 32 | 33 | If you would like to discuss the matter, you can use Github discussion/issues, I will be happy to answer any question 34 | 35 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { env } from 'node:process'; 2 | import { createDB } from './framework/db'; 3 | import { createBankAccountCommands } from './modules/bank-account'; 4 | 5 | const { 6 | POSTGRES_HOST, 7 | POSTGRES_DB, 8 | POSTGRES_PASSWORD, 9 | POSTGRES_USER, 10 | POSTGRES_PORT, 11 | } = env; 12 | 13 | const abortController = new AbortController(); 14 | 15 | const db = createDB({ 16 | host: POSTGRES_HOST, 17 | port: Number(POSTGRES_PORT), 18 | password: POSTGRES_PASSWORD, 19 | database: POSTGRES_DB, 20 | user: POSTGRES_USER, 21 | signal: abortController.signal, 22 | }); 23 | 24 | (async () => { 25 | const bankAccountCommands = createBankAccountCommands({ 26 | db, 27 | }); 28 | 29 | await bankAccountCommands.transferMoney({ 30 | from: 1, 31 | to: 2, 32 | amount: 42_00, 33 | }); 34 | 35 | abortController.abort(); 36 | })(); 37 | -------------------------------------------------------------------------------- /src/modules/bank-account/bank-account.repository.ts: -------------------------------------------------------------------------------- 1 | import { BankAccount, BankAccountService } from './transfer-money.command'; 2 | import { DBClient, SQL } from '../../framework/db'; 3 | 4 | export const createBankAccountRepository = ({ 5 | db, 6 | }: { 7 | db: DBClient; 8 | }): BankAccountService => { 9 | return { 10 | async findOne({ bankAccountId }: { bankAccountId: number }) { 11 | const rows = await db.query(SQL` 12 | SELECT 13 | bank_account_id as "bankAccountId", 14 | balance as "balance" 15 | FROM 16 | bank_accounts 17 | WHERE 18 | bank_account_id = ${bankAccountId};`); 19 | return rows.at(0); 20 | }, 21 | async updateBalance({ 22 | bankAccountId, 23 | balance, 24 | }: { 25 | bankAccountId: number; 26 | balance: number; 27 | }) { 28 | await db.query(SQL` 29 | UPDATE 30 | bank_accounts 31 | SET 32 | balance=${balance} 33 | WHERE 34 | bank_account_id=${bankAccountId} 35 | ;`); 36 | }, 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/framework/db.d.ts: -------------------------------------------------------------------------------- 1 | import { PoolConfig, QueryResultRow, Submittable } from 'pg'; 2 | import { SQLStatement } from 'sql-template-strings'; 3 | export { SQL } from 'sql-template-strings'; 4 | 5 | type TransactionHandler = ({ db }: { db: DBClient }) => Promise; 6 | 7 | type TransactionIsolationLevel = 8 | | 'SERIALIZABLE' 9 | | 'REPEATABLE READ' 10 | | 'READ COMMITTED'; 11 | 12 | type WithinTransactionParams = 13 | | TransactionHandler 14 | | { 15 | fn: TransactionHandler; 16 | transactionIsolationLevel: TransactionIsolationLevel; 17 | }; 18 | 19 | export type DBClient = { 20 | one( 21 | query: string | SQLStatement, 22 | ): Promise; 23 | query( 24 | query: string | SQLStatement, 25 | ): Promise; 26 | withinTransaction( 27 | handlerOrOptions: WithinTransactionParams, 28 | ): Promise; 29 | }; 30 | 31 | export declare function createDB( 32 | config: PoolConfig & { signal: AbortSignal }, 33 | ): DBClient; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 RENARD Laurent 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/modules/bank-account/bank-account.schema.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema } from 'json-schema-to-ts'; 2 | 3 | const bankAccountId = { 4 | type: 'integer', 5 | description: 'The unique identifier of a bank account', 6 | } as const satisfies JSONSchema; 7 | 8 | const amount = { 9 | type: 'integer', 10 | description: 'A monetary amount, in cents', 11 | } as const satisfies JSONSchema; 12 | 13 | const transferMoneyCommand = { 14 | type: 'object', 15 | properties: { 16 | input: { 17 | type: 'object', 18 | properties: { 19 | from: bankAccountId, 20 | to: bankAccountId, 21 | amount, 22 | }, 23 | additionalProperties: false, 24 | required: ['from', 'to', 'amount'], 25 | }, 26 | }, 27 | required: ['input'], 28 | additionalProperties: false, 29 | } as const satisfies JSONSchema; 30 | 31 | export const bankAccountSchema = { 32 | $id: 'bank-accounts.json', 33 | title: 'bank accounts service', 34 | description: 'The bank accounts service definition', 35 | type: 'object', 36 | properties: { 37 | commands: { 38 | type: 'object', 39 | properties: { 40 | transferMoney: transferMoneyCommand, 41 | }, 42 | required: ['transferMoney'], 43 | additionalProperties: false, 44 | }, 45 | }, 46 | required: ['commands'], 47 | additionalProperties: false, 48 | } as const satisfies JSONSchema; 49 | -------------------------------------------------------------------------------- /src/modules/bank-account/transfer-money.command.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { CommandFn } from '../../framework/command'; 3 | import { bankAccountSchema } from './bank-account.schema'; 4 | 5 | type TransferMoneyCommand = CommandFn< 6 | typeof bankAccountSchema, 7 | 'transferMoney' 8 | >; 9 | 10 | export type BankAccount = { 11 | bankAccountId: number; 12 | balance: number; 13 | }; 14 | 15 | export type BankAccountService = { 16 | findOne({ 17 | bankAccountId, 18 | }: { 19 | bankAccountId: number; 20 | }): Promise; 21 | updateBalance({ bankAccountId, balance }: BankAccount): Promise; 22 | }; 23 | 24 | export const createTransferMoneyCommand = 25 | ({ 26 | bankAccountService, 27 | }: { 28 | bankAccountService: BankAccountService; 29 | }): TransferMoneyCommand => 30 | async ({ from, to, amount }) => { 31 | const [fromAccount, toAccount] = await Promise.all( 32 | [from, to].map((bankAccountId) => 33 | bankAccountService.findOne({ bankAccountId }), 34 | ), 35 | ); 36 | assert(fromAccount, 'origin account does not exist'); 37 | assert(toAccount, 'target account does not exist'); 38 | 39 | const newToBalance = toAccount.balance + amount; 40 | const newFromBalance = fromAccount.balance - amount; 41 | 42 | if (newFromBalance < 0) { 43 | throw new Error('fund insufficient'); 44 | } 45 | 46 | await Promise.all([ 47 | bankAccountService.updateBalance({ 48 | bankAccountId: fromAccount.bankAccountId, 49 | balance: newFromBalance, 50 | }), 51 | bankAccountService.updateBalance({ 52 | bankAccountId: toAccount.bankAccountId, 53 | balance: newToBalance, 54 | }), 55 | ]); 56 | }; 57 | -------------------------------------------------------------------------------- /src/framework/command.d.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema, FromSchema } from 'json-schema-to-ts'; 2 | import { ProviderFn } from 'dismoi'; 3 | 4 | /** 5 | * Represents the structure of commands derived from the schema. 6 | */ 7 | type CommandsDef = 8 | FromSchema extends { commands: infer Commands } ? Commands : never; 9 | 10 | /** 11 | * The input type for a given command based on the schema. 12 | */ 13 | type CommandInput< 14 | Schema extends JSONSchema, 15 | Name extends keyof CommandsDef, 16 | > = CommandsDef[Name] extends { input: infer Input } ? Input : never; 17 | 18 | /** 19 | * Represents a command function (it always return a Promise for now). 20 | */ 21 | export type CommandFn< 22 | Schema extends JSONSchema, 23 | Name extends keyof CommandsDef, 24 | > = (input: CommandInput) => Promise; 25 | 26 | /** 27 | * Represents a mapping of command names to an injectable factory. 28 | */ 29 | type CommandInjectables = { 30 | [CommandName in keyof CommandsDef]: ( 31 | deps: any, 32 | ) => CommandFn; 33 | }; 34 | 35 | /** 36 | * A utility type to ensure two types are exactly the same (no extra fields). 37 | */ 38 | type Exact = T extends U ? (U extends T ? T : never) : never; 39 | 40 | /** 41 | * Function to define commands, ensuring that the commands match the expected schema. 42 | */ 43 | export declare function defineCommands< 44 | Schema extends JSONSchema, 45 | Injectables extends Record, 46 | Commands extends CommandInjectables, 47 | >(input: { 48 | schema: Schema; 49 | commands: Exact>; // Enforce strict matching 50 | injectables: Injectables; 51 | }): ProviderFn)[]>; 52 | -------------------------------------------------------------------------------- /src/framework/db.js: -------------------------------------------------------------------------------- 1 | import { Pool } from 'pg'; 2 | import { SQL } from 'sql-template-strings'; 3 | 4 | export { createDB, SQL }; 5 | 6 | const getTransactionParameters = (handlerOrOptions) => { 7 | return typeof handlerOrOptions === 'function' 8 | ? { 9 | fn: handlerOrOptions, 10 | transactionIsolationLevel: 'READ COMMITTED', 11 | } 12 | : handlerOrOptions; 13 | }; 14 | 15 | const createClient = ({ client }) => { 16 | const db = { 17 | async query(query) { 18 | const { rows } = await client.query(query); 19 | return rows; 20 | }, 21 | async one(query) { 22 | const rows = await db.query(query); 23 | return rows.at(0); 24 | }, 25 | withinTransaction(handlerOrOptions) { 26 | const { fn } = getTransactionParameters(handlerOrOptions); 27 | return fn({ db }); 28 | }, 29 | }; 30 | 31 | return db; 32 | }; 33 | 34 | function createDB({ signal, ...config }) { 35 | const pool = new Pool(config); 36 | signal.addEventListener( 37 | 'abort', 38 | () => { 39 | pool.end(); 40 | }, 41 | { once: true }, 42 | ); 43 | return { 44 | ...createClient({ client: pool }), 45 | async withinTransaction(handlerOrOptions) { 46 | const pgClient = await pool.connect(); 47 | const { transactionIsolationLevel, fn } = 48 | getTransactionParameters(handlerOrOptions); 49 | const db = createClient({ client: pgClient }); 50 | try { 51 | await db.query( 52 | `BEGIN TRANSACTION ISOLATION LEVEL ${transactionIsolationLevel};`, 53 | ); 54 | const result = await fn({ db }); 55 | await db.query('COMMIT'); 56 | return result; 57 | } catch (error) { 58 | await db.query('ROLLBACK;'); 59 | throw error; 60 | } finally { 61 | pgClient.release(); 62 | } 63 | }, 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /src/framework/command.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { createProvider } from 'dismoi'; 3 | import Ajv from 'ajv/dist/2020'; 4 | import { mapValues } from './utils'; 5 | 6 | import { provideSymbol } from 'dismoi'; 7 | 8 | export { defineCommands }; 9 | 10 | const withValidationDecorator = (commandFactory, schema) => { 11 | const validate = ajv.compile(schema); 12 | return (deps) => { 13 | const command = commandFactory(deps); 14 | return (input) => { 15 | const isValid = validate(input); 16 | if (!isValid) { 17 | throw new Error('Invalid command input', { cause: validate.errors }); 18 | } 19 | return command(input); 20 | }; 21 | }; 22 | }; 23 | 24 | const withinTransactionDecorator = 25 | (commandFactory) => 26 | ({ db, [provideSymbol]: provide }) => { 27 | return (commandInput) => 28 | db.withinTransaction({ 29 | fn: ({ db }) => { 30 | const deps = provide({ db }); 31 | const command = commandFactory(deps); 32 | return command(commandInput); 33 | }, 34 | transactionIsolationLevel: 'REPEATABLE READ', 35 | }); 36 | }; 37 | 38 | const ajv = new Ajv(); 39 | 40 | function defineCommands({ commands, schema, injectables }) { 41 | ajv.addSchema(schema); 42 | const commandListFromSchema = Object.keys( 43 | schema.properties.commands.properties, 44 | ); 45 | const commandListFromImplementation = Object.keys(commands); 46 | const symmetricDifference = new Set( 47 | commandListFromImplementation, 48 | ).symmetricDifference(new Set(commandListFromSchema)); 49 | assert( 50 | symmetricDifference.size === 0, 51 | `discrepancy between schema and implementation: [${[...symmetricDifference]}]`, 52 | ); 53 | 54 | const commandWithinTransaction = mapValues( 55 | commands, 56 | withinTransactionDecorator, 57 | ); 58 | 59 | const commandWithValidation = mapValues( 60 | commandWithinTransaction, 61 | (commandFactory, commandName) => { 62 | const inputSchema = 63 | schema.properties.commands.properties[commandName].properties.input; 64 | return withValidationDecorator(commandFactory, inputSchema); 65 | }, 66 | ); 67 | 68 | const _injectables = { 69 | ...injectables, 70 | ...commandWithValidation, 71 | }; 72 | 73 | return createProvider({ 74 | injectables: _injectables, 75 | api: Object.keys(commands), 76 | }); 77 | } 78 | --------------------------------------------------------------------------------