NestJS / 38 минут
Репозитории, БД и транзакции
Разделите сценарии и хранилище, изучите транзакции и конфликты конкурентных записей.
- Разделите сценарии и хранилище, изучите транзакции и конфликты конкурентных записей.
- Применить тему в задаче «Репозитории, БД и транзакции» и объяснить граничные случаи.
Около 8 минут на чтение и разбор примеров. Практика — отдельно, в своём темпе.
Постоянные данные и сценарий
Массив внутри процесса удобен для первого примера, но исчезает после перезапуска и не разделяется между несколькими экземплярами API. Постоянное хранение требует БД и явно заданной модели согласованности. Nest организует зависимости, однако сам по себе не делает запись атомарной и не защищает от конкурентных запросов.
Разделите бизнес-сценарий и адаптер хранения. Контроллер принимает запрос, сервис выполняет действие, репозиторий предоставляет нужные операции. Не обязательно создавать универсальный CRUD для каждой таблицы. Узкий контракт findById или reserveBook точнее выражает потребность сценария, чем десятки методов, которые никто не использует.
interface Book { id: string; title: string }
interface BookRepository {
find(id: string): Promise<Book | null>;
}
const BOOKS = Symbol('BOOKS');Контракт и адаптер
TypeScript interface не существует во время исполнения, поэтому DI использует отдельный токен. Сервис получает контракт, а модуль связывает его с реализацией. В тесте можно предоставить in-memory репозиторий, сохранив смысл возвращаемого значения и поведение отсутствующей записи. Пустая строка title не равна отсутствию книги.
@Injectable()
class BooksService {
constructor(@Inject(BOOKS) private readonly books: BookRepository) {}
async title(id: string) {
const book = await this.books.find(id);
return book?.title ?? null;
}
}В учебном упражнении find синхронный ради небольшого контракта; в приложении с БД обычно нужен Promise. Не меняйте бизнес-смысл ошибки при подмене адаптера. Если реальная БД сообщает конфликт уникальности, тестовая реализация тоже должна позволять проверить обработку такого конфликта, а не молча перезаписывать запись.
Prisma как инфраструктура
Prisma описывает схему, генерирует типизированный клиент и выполняет запросы. Миграция изменяет БД, генерация клиента создаёт код для приложения; одно не заменяет другое. Храните версионируемые миграции в Git, а реальные credentials — в окружении. Проверяйте миграции на отдельной БД до применения к данным пользователей.
В Prisma 7 конфигурация подключения, driver adapter и генератор клиента отличаются от старых руководств. Используйте документацию установленной major-версии и проверяйте фактический output генератора. Для PostgreSQL адаптер и клиент оформляют общим инфраструктурным провайдером, а feature-модули получают его через DI. Не создавайте новый пул соединений в каждом обработчике.
const books = await this.db.book.findMany({
where: { ownerId: userId },
select: { id: true, title: true },
take: 50,
});Пример предполагает соответствующую модель book в схеме. Явный select ограничивает публичные поля, where связывает чтение с пользователем, take ограничивает объём. Параметризованные API предпочтительнее конкатенации SQL из пользовательского текста. Динамические сортировки выбирайте из разрешённого набора полей.
Атомарность связанных изменений
Если выдача книги требует создать запись займа и уменьшить остаток, частичный успех нарушает состояние. Транзакция объединяет связанные операции: либо согласованный набор фиксируется, либо изменения откатываются. При этом транзакция не делает неверное бизнес-правило правильным и не заменяет выбранный уровень изоляции.
await this.db.$transaction(async tx => {
const changed = await tx.book.updateMany({
where: { id, available: { gt: 0 } },
data: { available: { decrement: 1 } },
});
if (changed.count !== 1) throw new ConflictException('Нет экземпляров');
await tx.loan.create({ data: { bookId: id, userId } });
});Схема здесь содержит available и loan. Условное обновление важно: два запроса могут одновременно прочитать старый остаток. Не держите транзакцию открытой во время долгого HTTP-запроса к стороннему сервису. Для внешних действий понадобятся отдельные решения, например идемпотентность и надёжная очередь, а не иллюзия общей транзакции с интернетом.
Конкуренция и ограничения
Предварительный find не защищает уникальный email при гонке. Два запроса могут оба не найти запись. Ограничение unique в БД является окончательным гарантом, а сервис переводит конфликт в стабильный внешний ответ. Аналогично проверка владельца должна учитываться в операции изменения, если состояние может поменяться между чтением и записью.
Повторный запрос клиента после таймаута не обязательно означает новое действие. Для операций с побочным эффектом определите идемпотентный ключ и уникальность его обработки. В JSlab начисление XP использует уникальность пары аккаунт/этап; повторное успешное решение не должно повторно выдавать награду. Кнопка disabled в браузере не обеспечивает это свойство на сервере.
Практика репозитория
Зарегистрируйте REPOSITORY с методом find по переданному массиву. BooksService получает его через Inject и возвращает title либо null. Получите сервис из application context, выполните поиск и закройте контекст. В задаче нет настоящей БД и сетевого соединения: она проверяет границу зависимости, а не моделирует свойства транзакций массива.
После решения добавьте PostgreSQL в локальный итоговый проект, миграцию книги и уникальный бизнес-ключ. Проверьте сохранение после перезапуска, отсутствие записи, конфликт и откат связанных изменений. Интеграционные тесты запускайте на отдельной test-базе: тестовый сценарий не должен очищать реальные аккаунты и прогресс.