From e3ecb8c15e61ccd8de9acb4954a38932099eefc3 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 4 Nov 2026 16:53:41 +0300 Subject: [PATCH] refactor: enforce repository boundary in application (WealthLedger/backend#171) --- .ai/architecture.md | 12 +++++------ .ai/conventions/data.md | 20 +++++++++---------- eslint.config.mjs | 5 +++-- src/modules/auth/application/auth.service.ts | 3 ++- .../application/bank-cards.service.ts | 3 ++- .../application/bank-operations.service.ts | 3 ++- test/eslint-config.cjs | 3 ++- 7 files changed, 27 insertions(+), 22 deletions(-) diff --git a/.ai/architecture.md b/.ai/architecture.md index a0440a2..78a0c26 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -29,9 +29,9 @@ market — котировками, income — выплатами/личными domain из-за extraction в функцию. Boundary mappers преобразуют внешние records; чистые расчёты portfolio/banking остаются у domain owner. -PrismaService владеет DB lifecycle. Самостоятельные use cases получают данные -через concrete repositories; существующие связные CRUD/transaction services -сохраняют прямые queries по [data convention](conventions/data.md). +PrismaService владеет DB lifecycle. Application services/use cases получают +данные через concrete repositories; запросы и CAS живут в infrastructure +по [data convention](conventions/data.md). Money — currency + decimal string; расчёты используют точные общие helpers. Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и @@ -201,9 +201,9 @@ Circular dependencies устраняй изменением ownership/compositio конкретные infrastructure providers, зарегистрированные module. Infrastructure может использовать application types/input и domain rules; новые interfaces ради инверсии зависимостей не обязательны. Domain не импортирует presentation или -infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries -по [Data](conventions/data.md); use case получает данные через repository и сам -Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется. +infrastructure. Application не строит Prisma queries: use case и связный CRUD +service обращаются к репозиторию по [Data](conventions/data.md). Entity/interface +на каждую таблицу не требуется. DTO остаются транспортными runtime classes; существующее использование DTO на application boundary допустимо по [HTTP API](conventions/http-api.md). diff --git a/.ai/conventions/data.md b/.ai/conventions/data.md index b79cc76..ea74b2f 100644 --- a/.ai/conventions/data.md +++ b/.ai/conventions/data.md @@ -34,19 +34,19 @@ candidate, не повод дублировать их при новом use cas вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records не становятся domain entities: расчёт получает только нужные ему значения. -Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction -services (auth, lifecycle подключения, изменения карт/операций/категорий) -сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует -лишь набора pass-through методов. Новая самостоятельная read/write capability -получает repository method; самостоятельный use case не выполняет Prisma queries. -`BankCardsRepository.write` сохраняет общую retry/transaction boundary для -существующих banking services. Такой callback с Prisma transaction client — -локальная техническая граница этих services, не API для domain или use cases. +Application-код не импортирует PrismaService и не строит Prisma queries. +Репозиторий владеет запросом и CAS-проверкой результата, а application — +правилами и формированием ответа. Типы Prisma допустимы только в type-only +импортах для существующей транзакционной границы. Не создавай обёртку +на каждый вызов Prisma без текущего потребителя. `BankCardsRepository.write` +сохраняет retry/transaction boundary для банковских записей; callback с +transaction client — локальная техническая граница, не API для domain. Выбирай только нужные данные, сохраняй user/account ownership в запросах и application checks. Предварительный lookup не заменяет unique constraint при race. -Пример: AuthService обрабатывает P2002 и использует conditional `updateMany` -для refresh rotation. Сначала пойми invariant; не копируй его flow в другой use case. +Пример: AuthRepository преобразует P2002 в результат регистрации и выполняет +conditional `updateMany` для refresh rotation; AuthService сохраняет HTTP +error contract. Сначала пойми invariant; не копируй его flow в другой use case. Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB операция. Планируй порядок и compensation по текущей задаче; не удерживай DB diff --git a/eslint.config.mjs b/eslint.config.mjs index d5a9e23..b44f066 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -158,13 +158,14 @@ export default defineConfig( }, }, { - files: ['src/**/*.use-case.ts'], + files: ['src/**/application/**/*.ts'], ignores: tests, rules: { 'no-restricted-imports': ['error', { patterns: [{ group: prismaImports, - message: 'Use cases obtain data through concrete repositories.', + allowTypeImports: true, + message: 'Application code obtains data through concrete repositories; type-only imports are allowed.', }], }], }, diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 5682b57..2462d24 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -7,7 +7,8 @@ import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { createHash, randomUUID } from 'crypto'; -import { Session, User } from '../../../../generated/prisma/client'; +import type { Session, User } from '../../../../generated/prisma/client'; + import { HttpErrorCode } from '../../../common/error-code'; import { AuthRepository } from '../infrastructure/auth.repository'; import { AuthErrorCode } from './auth-error-code'; diff --git a/src/modules/banking/bank-cards/application/bank-cards.service.ts b/src/modules/banking/bank-cards/application/bank-cards.service.ts index 96685b0..e8db044 100644 --- a/src/modules/banking/bank-cards/application/bank-cards.service.ts +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { BankCard, Prisma } from '../../../../../generated/prisma/client'; +import type { BankCard, Prisma } from '../../../../../generated/prisma/client'; + import { bankBalance } from '../domain/bank-card.calculations'; import { rubMoney } from '../domain/rub-money'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts index e4bdfe0..bd036f0 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -1,9 +1,10 @@ import { GoneException, Injectable } from '@nestjs/common'; -import { +import type { BankCardOperation, Prisma, } from '../../../../../generated/prisma/client'; + import { FIRST_PAGE } from '../domain/bank-card-limits'; import { BankOperationKind } from '../domain/bank-operation-kind'; import { diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs index 4a8b9d1..69fb37b 100644 --- a/test/eslint-config.cjs +++ b/test/eslint-config.cjs @@ -102,6 +102,7 @@ test('architecture imports enforce direct boundaries without banning DTOs or pur [controller, 'import { PrismaService } from "../../../prisma/prisma.service";'], [controller, 'import { TbankClient } from "../../../integrations/tbank/tbank.api";'], [useCase, 'import { PrismaService } from "../../../../prisma/prisma.service";'], + ['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import { PrismaService } from "../../../../prisma/prisma.service";'], ]) { const [result] = await eslint.lintText(code, { filePath }); assert.equal(result.fatalErrorCount, 0); @@ -113,7 +114,7 @@ test('architecture imports enforce direct boundaries without banning DTOs or pur [controller, 'import { AuthService } from "../application/auth.service";'], [useCase, 'import { BankCardsRepository } from "../infrastructure/bank-cards.repository";'], [useCase, 'import { BankSummaryQueryDto } from "../presentation/bank-card.dto";'], - ['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import { PrismaService } from "../../../../prisma/prisma.service";'], + ['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import type { Prisma } from "../../../../../generated/prisma/client";'], ]) { const [result] = await eslint.lintText(code, { filePath }); assert.equal(result.fatalErrorCount, 0);