├── .prettierrc ├── tsconfig.build.json ├── src ├── prisma │ ├── unit-of-work.ts │ ├── prisma.module.ts │ └── prisma-provider.ts ├── user │ ├── controllers │ │ ├── create-user.dto.ts │ │ └── user.controller.ts │ ├── dal │ │ └── user.repository.ts │ └── domain │ │ └── user.service.ts ├── main.ts └── app.module.ts ├── prisma ├── migrations │ ├── 20230803104938_run │ │ └── migration.sql │ ├── migration_lock.toml │ └── 20230731134829_initial │ │ └── migration.sql └── schema.prisma ├── nest-cli.json ├── .github ├── dependabot.yml └── workflows │ └── ci.yaml ├── test ├── jest-e2e.json └── app.e2e-spec.ts ├── README.md ├── .gitignore ├── tsconfig.json ├── LICENSE ├── eslint.config.mjs └── package.json /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/prisma/unit-of-work.ts: -------------------------------------------------------------------------------- 1 | export abstract class UnitOfWork { 2 | abstract transaction(fn: () => Promise): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /prisma/migrations/20230803104938_run/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX "User_email_key"; 3 | 4 | -- DropIndex 5 | DROP INDEX "User_username_key"; 6 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' # See documentation for possible values 4 | directory: '/' # Location of package manifests 5 | schedule: 6 | interval: 'weekly' 7 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/user/controllers/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class CreateUserDto { 4 | @ApiProperty() 5 | username: string; 6 | @ApiProperty() 7 | email: string; 8 | @ApiProperty() 9 | name: string; 10 | @ApiProperty() 11 | age: number; 12 | createdAt?: Date | string; 13 | updatedAt?: Date | string; 14 | } 15 | -------------------------------------------------------------------------------- /src/prisma/prisma.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PrismaProvider } from './prisma-provider'; 3 | import { UnitOfWork } from './unit-of-work'; 4 | 5 | @Module({ 6 | providers: [ 7 | { 8 | provide: UnitOfWork, 9 | useValue: PrismaProvider, 10 | }, 11 | PrismaProvider, 12 | ], 13 | exports: [UnitOfWork, PrismaProvider], 14 | }) 15 | export class DatabaseModule {} 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unit of work pattern for PrismaORM 2 | 3 | ```typescript 4 | return this.unitOfWork.runInTransaction(async (tx) => { 5 | const updatedAggregate1 = await this.repo.saveAggregate1(entity, { tx }); 6 | await this.repo2.saveAggregate2(entity, { tx }); 7 | return updatedAggregate1; 8 | }); 9 | ``` 10 | 11 | ![image](https://github.com/zhuravlevma/prisma-unit-of-work/assets/44276887/6ebd10bd-fd88-42cb-8c7c-71a162283e04) 12 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id Int @id @default(autoincrement()) 15 | username String 16 | email String 17 | name String 18 | age Int 19 | createdAt DateTime @default(now()) 20 | updatedAt DateTime @updatedAt 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | .env 15 | 16 | # OS 17 | .DS_Store 18 | 19 | # Tests 20 | /coverage 21 | /.nyc_output 22 | 23 | # IDEs and editors 24 | /.idea 25 | .project 26 | .classpath 27 | .c9/ 28 | *.launch 29 | .settings/ 30 | *.sublime-workspace 31 | 32 | # IDE - VSCode 33 | .vscode/* 34 | !.vscode/settings.json 35 | !.vscode/tasks.json 36 | !.vscode/launch.json 37 | !.vscode/extensions.json 38 | -------------------------------------------------------------------------------- /prisma/migrations/20230731134829_initial/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" SERIAL NOT NULL, 4 | "username" TEXT NOT NULL, 5 | "email" TEXT NOT NULL, 6 | "name" TEXT NOT NULL, 7 | "age" INTEGER NOT NULL, 8 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | "updatedAt" TIMESTAMP(3) NOT NULL, 10 | 11 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 12 | ); 13 | 14 | -- CreateIndex 15 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); 16 | 17 | -- CreateIndex 18 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 19 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | 8 | const config = new DocumentBuilder() 9 | .setTitle('Cats example') 10 | .setDescription('The cats API description') 11 | .setVersion('1.0') 12 | .addTag('cats') 13 | .build(); 14 | const document = SwaggerModule.createDocument(app, config); 15 | SwaggerModule.setup('api', app, document); 16 | 17 | await app.listen(3000); 18 | } 19 | bootstrap(); 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/user/dal/user.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Prisma, User } from '@prisma/client'; 3 | import { PrismaProvider } from 'src/prisma/prisma-provider'; 4 | 5 | @Injectable() 6 | export class UserRepository { 7 | constructor(private readonly prisma: PrismaProvider) {} 8 | 9 | async getUsers(): Promise { 10 | return this.prisma.client.user.findMany(); 11 | } 12 | 13 | async create(body: Prisma.UserCreateInput): Promise { 14 | return this.prisma.client.user.create({ 15 | data: body, 16 | }); 17 | } 18 | 19 | async update(userId: number, userData: Prisma.UserUpdateInput): Promise { 20 | return this.prisma.client.user.update({ 21 | where: { id: userId }, 22 | data: userData, 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserController } from './user/controllers/user.controller'; 3 | import { UserService } from './user/domain/user.service'; 4 | import { UserRepository } from './user/dal/user.repository'; 5 | import { ConfigModule } from '@nestjs/config'; 6 | import { PrismaProvider } from './prisma/prisma-provider'; 7 | import { DatabaseModule } from './prisma/prisma.module'; 8 | 9 | @Module({ 10 | imports: [ 11 | ConfigModule.forRoot({ 12 | isGlobal: true, 13 | }), 14 | DatabaseModule, 15 | ], 16 | controllers: [UserController], 17 | providers: [ 18 | { 19 | provide: UserService, 20 | useFactory: (a, b) => new UserService(a, b), 21 | inject: [UserRepository, PrismaProvider], 22 | }, 23 | UserRepository, 24 | ], 25 | }) 26 | export class AppModule {} 27 | -------------------------------------------------------------------------------- /src/user/domain/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Prisma, User } from '@prisma/client'; 3 | import { UserRepository } from '../dal/user.repository'; 4 | import { PrismaProvider } from 'src/prisma/prisma-provider'; 5 | @Injectable() 6 | export class UserService { 7 | constructor( 8 | private readonly userRepository: UserRepository, 9 | private readonly prisma: PrismaProvider, 10 | ) {} 11 | 12 | async getUser(): Promise { 13 | return this.userRepository.getUsers(); 14 | } 15 | 16 | async createUser(userData: Prisma.UserCreateInput): Promise { 17 | try { 18 | return this.prisma.transaction(async () => { 19 | await this.userRepository.create(userData); 20 | return this.userRepository.update(1, { 21 | age: Math.floor(Math.random() * 100), 22 | }); 23 | }); 24 | } catch (err) { 25 | console.error(err); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Maksim Zhuravlev 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/prisma/prisma-provider.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PrismaClient, Prisma } from '@prisma/client'; 3 | import { AsyncLocalStorage } from 'async_hooks'; 4 | import { UnitOfWork } from './unit-of-work'; 5 | 6 | type PrismaTransaction = () => Promise; 7 | 8 | @Injectable() 9 | export class PrismaProvider extends PrismaClient implements UnitOfWork { 10 | private asyncLocalStorage = new AsyncLocalStorage< 11 | Prisma.TransactionClient | PrismaProvider 12 | >(); 13 | 14 | async onModuleInit() { 15 | await this.$connect(); 16 | } 17 | async onModuleDestroy() { 18 | await this.$disconnect(); 19 | } 20 | 21 | async transaction(fn: PrismaTransaction) { 22 | return this.$transaction(async (transactionClient) => { 23 | return this.asyncLocalStorage.run(transactionClient, async () => { 24 | try { 25 | const result = await fn(); 26 | return result; 27 | } catch (error) { 28 | console.error('Transaction error:', error); 29 | throw error; 30 | } 31 | }); 32 | }); 33 | } 34 | 35 | get client(): PrismaClient | Prisma.TransactionClient { 36 | const transactionClient = this.asyncLocalStorage.getStore(); 37 | 38 | return transactionClient ?? this; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/user/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Post, 4 | Body, 5 | HttpStatus, 6 | HttpException, 7 | Get, 8 | } from '@nestjs/common'; 9 | import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; 10 | import { UserService } from '../domain/user.service'; 11 | import { CreateUserDto } from './create-user.dto'; 12 | 13 | @Controller('users') 14 | @ApiTags('users') 15 | export class UserController { 16 | constructor(private readonly userService: UserService) {} 17 | 18 | @Get() 19 | async getUsers() { 20 | try { 21 | const user = await this.userService.getUser(); 22 | return user; 23 | } catch (error) { 24 | console.error(error); 25 | throw new HttpException( 26 | 'Failed to create user', 27 | HttpStatus.INTERNAL_SERVER_ERROR, 28 | ); 29 | } 30 | } 31 | 32 | @Post('/') 33 | @ApiOperation({ summary: 'Create a new user' }) 34 | @ApiBody({ type: CreateUserDto }) 35 | async createUser(@Body() userData: CreateUserDto) { 36 | try { 37 | const user = await this.userService.createUser(userData); 38 | return user; 39 | } catch (error) { 40 | console.error(error); 41 | throw new HttpException( 42 | 'Failed to create user', 43 | HttpStatus.INTERNAL_SERVER_ERROR, 44 | ); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import typescriptEslintEslintPlugin from '@typescript-eslint/eslint-plugin'; 2 | import globals from 'globals'; 3 | import tsParser from '@typescript-eslint/parser'; 4 | import path from 'node:path'; 5 | import { fileURLToPath } from 'node:url'; 6 | import js from '@eslint/js'; 7 | import { FlatCompat } from '@eslint/eslintrc'; 8 | 9 | const __filename = fileURLToPath(import.meta.url); 10 | const __dirname = path.dirname(__filename); 11 | const compat = new FlatCompat({ 12 | baseDirectory: __dirname, 13 | recommendedConfig: js.configs.recommended, 14 | allConfig: js.configs.all, 15 | }); 16 | 17 | export default [ 18 | ...compat.extends('plugin:@typescript-eslint/recommended'), 19 | { 20 | plugins: { 21 | '@typescript-eslint': typescriptEslintEslintPlugin, 22 | }, 23 | 24 | languageOptions: { 25 | globals: { 26 | ...globals.node, 27 | ...globals.jest, 28 | }, 29 | 30 | parser: tsParser, 31 | ecmaVersion: 5, 32 | sourceType: 'module', 33 | 34 | parserOptions: { 35 | project: 'tsconfig.json', 36 | }, 37 | }, 38 | 39 | rules: { 40 | '@typescript-eslint/interface-name-prefix': 'off', 41 | '@typescript-eslint/explicit-function-return-type': 'off', 42 | '@typescript-eslint/explicit-module-boundary-types': 'off', 43 | '@typescript-eslint/no-explicit-any': 'off', 44 | '@typescript-eslint/no-unsafe-function-type': 'off', 45 | '@typescript-eslint/no-empty-object-type': 'off', 46 | }, 47 | }, 48 | ]; 49 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Run production pipeline 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | 8 | jobs: 9 | install_dep: 10 | name: Install dep 11 | runs-on: ubuntu-latest 12 | container: 13 | image: node:22 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Cache Node Modules 17 | id: cache-node-modules 18 | uses: actions/cache@v4 19 | with: 20 | path: node_modules 21 | key: node-modules-${{ hashFiles('package-lock.json') }} 22 | - name: Install Dependencies 23 | run: npm ci --ignore-scripts 24 | if: steps.cache-node-modules.outputs.cache-hit != 'true' 25 | eslint: 26 | needs: install_dep 27 | name: ESLint 28 | runs-on: ubuntu-latest 29 | container: 30 | image: node:22 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Cache Node Modules 34 | id: cache-node-modules 35 | uses: actions/cache@v4 36 | with: 37 | path: node_modules 38 | key: node-modules-${{ hashFiles('package-lock.json') }} 39 | - name: Lint source code with ESLint 40 | run: npm run lint:check 41 | prettier: 42 | needs: install_dep 43 | name: Prettier 44 | runs-on: ubuntu-latest 45 | container: 46 | image: node:22 47 | steps: 48 | - uses: actions/checkout@v4 49 | - name: Cache Node Modules 50 | id: cache-node-modules 51 | uses: actions/cache@v4 52 | with: 53 | path: node_modules 54 | key: node-modules-${{ hashFiles('package-lock.json') }} 55 | - name: Lint source code with Prettier 56 | run: npm run format:check 57 | unit_testing: 58 | needs: [eslint, prettier] 59 | name: Unit testing 60 | runs-on: ubuntu-latest 61 | container: 62 | image: node:22 63 | steps: 64 | - uses: actions/checkout@v4 65 | - name: Cache Node Modules 66 | id: cache-node-modules 67 | uses: actions/cache@v4 68 | with: 69 | path: node_modules 70 | key: node-modules-${{ hashFiles('package-lock.json') }} 71 | - name: Run tests 72 | run: npm run test 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unit-of-work-ddd", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "start": "nest start", 11 | "start:dev": "nest start --watch", 12 | "start:debug": "nest start --debug --watch", 13 | "start:prod": "node dist/main", 14 | "format": "npx prettier --write 'src/**/*.{ts,js}'", 15 | "format:check": "npx prettier --list-different 'src/**/*.{ts,js}'", 16 | "lint": "npx eslint --fix --quiet \"{src,apps,libs,test}/**/*.ts\"", 17 | "lint:check": "npx eslint \"{src,apps,libs,test}/**/*.ts\"", 18 | "test": "jest --passWithNoTests", 19 | "test:watch": "jest --watch", 20 | "test:cov": "jest --coverage", 21 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 22 | "test:e2e": "jest --config ./test/jest-e2e.json" 23 | }, 24 | "dependencies": { 25 | "@nestjs/common": "^10.4.15", 26 | "@nestjs/config": "^3.3.0", 27 | "@nestjs/core": "^10.4.15", 28 | "@nestjs/platform-express": "^10.4.15", 29 | "@nestjs/swagger": "^8.1.0", 30 | "@prisma/client": "^6.1.0", 31 | "reflect-metadata": "^0.2.2", 32 | "rxjs": "^7.8.1" 33 | }, 34 | "devDependencies": { 35 | "@nestjs/cli": "^10.4.9", 36 | "@nestjs/schematics": "^10.2.3", 37 | "@nestjs/testing": "^10.4.15", 38 | "@types/express": "^5.0.0", 39 | "@types/jest": "29.5.14", 40 | "@types/node": "22.10.5", 41 | "@types/supertest": "^6.0.2", 42 | "@typescript-eslint/eslint-plugin": "^8.19.0", 43 | "@typescript-eslint/parser": "^8.19.0", 44 | "eslint": "^9.17.0", 45 | "eslint-config-prettier": "^9.1.0", 46 | "eslint-plugin-prettier": "^5.2.1", 47 | "jest": "29.7.0", 48 | "prettier": "^3.4.2", 49 | "prisma": "^6.1.0", 50 | "source-map-support": "^0.5.21", 51 | "supertest": "^7.0.0", 52 | "ts-jest": "29.2.5", 53 | "ts-loader": "^9.5.1", 54 | "ts-node": "^10.9.2", 55 | "tsconfig-paths": "4.2.0", 56 | "typescript": "^5.7.2" 57 | }, 58 | "jest": { 59 | "moduleFileExtensions": [ 60 | "js", 61 | "json", 62 | "ts" 63 | ], 64 | "rootDir": "src", 65 | "testRegex": ".*\\.spec\\.ts$", 66 | "transform": { 67 | "^.+\\.(t|j)s$": "ts-jest" 68 | }, 69 | "collectCoverageFrom": [ 70 | "**/*.(t|j)s" 71 | ], 72 | "coverageDirectory": "../coverage", 73 | "testEnvironment": "node" 74 | } 75 | } 76 | --------------------------------------------------------------------------------