Пошук уроків, статей та іншого контенту
Винесемо запити до бази в репозиторії та відокремимо інфраструктурний код від бізнес-логіки.
Repository Pattern — це підхід, за якого код доступу до даних винесено в окремий об’єкт — репозиторій.
Без репозиторію сервіс може напряму працювати з TypeORM:
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
async findById(id: number) {
return this.users.findOne({ where: { id } });
}
}У такому випадку UsersService залежить від конкретної ORM. Бізнес-логіка знає, що використовується TypeORM, і містить інфраструктурні деталі запитів.
Замість цього сервіс залежатиме від абстракції репозиторію:
@Injectable()
export class UsersService {
constructor(
@Inject(USER_REPOSITORY)
private readonly users: UserRepository,
) {}
}Тепер сервіс не знає:
яка ORM використовується;
як саме формується SQL-запит;
де зберігаються дані;
як створюється екземпляр сутності.
Він працює лише з операціями, потрібними бізнес-логіці.
Типова структура може виглядати так:
users/
├── user.entity.ts
├── user.repository.ts
├── typeorm-user.repository.ts
├── users.service.ts
└── users.module.tsРолі файлів:
user.entity.ts — модель для зберігання в базі;
user.repository.ts — контракт репозиторію;
typeorm-user.repository.ts — реалізація контракту через TypeORM;
users.service.ts — бізнес-логіка;
users.module.ts — налаштування залежностей NestJS.
Спочатку визначимо, які операції потрібні бізнес-логіці.
// user.repository.ts
import { User } from './user.entity';
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
export type CreateUserData = {
email: string;
name: string;
};
export interface UserRepository {
findById(id: number): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(data: CreateUserData): Promise<User>;
}UserRepository — це TypeScript-інтерфейс. Він описує можливості репозиторію, але не містить деталей їх реалізації.
USER_REPOSITORY потрібен для Dependency Injection. Інтерфейси TypeScript видаляються під час компіляції, тому NestJS не може використати UserRepository як runtime-токен.
Для токена використано Symbol, щоб уникнути конфліктів із рядками.
Не варто одразу додавати до контракту методи на кшталт:
find(options: FindManyOptions<User>): Promise<User[]>;Такий тип прив’язує контракт до TypeORM. Краще описувати операції мовою предметної області:
findByEmail(email: string): Promise<User | null>;Репозиторій має надавати бізнес-логіці потрібні операції, а не повторювати API конкретної бібліотеки.
// user.entity.ts
import {
Column,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('users')
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
email: string;
@Column()
name: string;
}Ця сутність описує структуру таблиці users. Вона використовується інфраструктурною реалізацією репозиторію, але не повинна змушувати бізнес-логіку знати про TypeORM-методи.
// typeorm-user.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
import {
CreateUserData,
UserRepository,
} from './user.repository';
@Injectable()
export class TypeOrmUserRepository implements UserRepository {
constructor(
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
findById(id: number): Promise<User | null> {
return this.users.findOne({
where: { id },
});
}
findByEmail(email: string): Promise<User | null> {
return this.users.findOne({
where: { email },
});
}
async save(data: CreateUserData): Promise<User> {
const user = this.users.create(data);
return this.users.save(user);
}
}Цей клас містить інфраструктурний код:
@InjectRepository(User);
Repository<User>;
findOne;
create;
save;
TypeORM-формат умови where.
Бізнес-сервісу не потрібно знати про жодну з цих деталей.
// users.service.ts
import {
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
CreateUserData,
USER_REPOSITORY,
UserRepository,
} from './user.repository';
@Injectable()
export class UsersService {
constructor(
@Inject(USER_REPOSITORY)
private readonly users: UserRepository,
) {}
async register(data: CreateUserData) {
const existingUser = await this.users.findByEmail(data.email);
if (existingUser) {
throw new ConflictException(
'Користувач із таким email вже існує',
);
}
return this.users.save(data);
}
async findById(id: number) {
const user = await this.users.findById(id);
if (!user) {
throw new NotFoundException('Користувача не знайдено');
}
return user;
}
}UsersService містить бізнес-правила:
перевірити, чи існує користувач із таким email;
якщо існує — повернути помилку;
якщо не існує — зберегти нового користувача;
під час пошуку відсутній користувач перетворюється на NotFoundException.
При цьому сервіс не знає, чи виконується запит до PostgreSQL, SQLite, зовнішнього API або тестового сховища в пам’яті.
// users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { TypeOrmUserRepository } from './typeorm-user.repository';
import { USER_REPOSITORY } from './user.repository';
import { UsersService } from './users.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [
UsersService,
TypeOrmUserRepository,
{
provide: USER_REPOSITORY,
useExisting: TypeOrmUserRepository,
},
],
exports: [UsersService],
})
export class UsersModule {}TypeOrmModule.forFeature([User]) робить TypeORM-репозиторій User доступним у цьому модулі.
Провайдер:
{
provide: USER_REPOSITORY,
useExisting: TypeOrmUserRepository,
}означає:
коли клас просить залежність із токеном USER_REPOSITORY;
NestJS має повернути вже створений екземпляр TypeOrmUserRepository.
useExisting важливий тим, що для UsersService і токена використовується один і той самий екземпляр реалізації.
Можна також використати useClass:
{
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,
}У такому разі NestJS налаштує клас як реалізацію токена. Для простої реалізації це також коректний варіант.
Нижче наведено узгоджений приклад основних частин модуля:
// user.repository.ts
import { User } from './user.entity';
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
export type CreateUserData = {
email: string;
name: string;
};
export interface UserRepository {
findById(id: number): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(data: CreateUserData): Promise<User>;
}// typeorm-user.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
import {
CreateUserData,
UserRepository,
} from './user.repository';
@Injectable()
export class TypeOrmUserRepository implements UserRepository {
constructor(
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
findById(id: number): Promise<User | null> {
return this.users.findOne({ where: { id } });
}
findByEmail(email: string): Promise<User | null> {
return this.users.findOne({ where: { email } });
}
async save(data: CreateUserData): Promise<User> {
const user = this.users.create(data);
return this.users.save(user);
}
}// users.service.ts
import {
ConflictException,
Inject,
Injectable,
} from '@nestjs/common';
import {
CreateUserData,
USER_REPOSITORY,
UserRepository,
} from './user.repository';
@Injectable()
export class UsersService {
constructor(
@Inject(USER_REPOSITORY)
private readonly users: UserRepository,
) {}
async register(data: CreateUserData) {
const existingUser = await this.users.findByEmail(data.email);
if (existingUser) {
throw new ConflictException(
'Користувач із таким email вже існує',
);
}
return this.users.save(data);
}
}// users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { TypeOrmUserRepository } from './typeorm-user.repository';
import { USER_REPOSITORY } from './user.repository';
import { UsersService } from './users.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [
UsersService,
TypeOrmUserRepository,
{
provide: USER_REPOSITORY,
useExisting: TypeOrmUserRepository,
},
],
exports: [UsersService],
})
export class UsersModule {}Цей код працює в NestJS-застосунку, де TypeORM налаштований через TypeOrmModule.forRoot(...).
Одна з переваг патерну — можливість замінити реалізацію без змін у сервісі.
Наприклад, для тестів можна створити репозиторій у пам’яті:
import { Injectable } from '@nestjs/common';
import { User } from './user.entity';
import {
CreateUserData,
UserRepository,
} from './user.repository';
@Injectable()
export class InMemoryUserRepository implements UserRepository {
private readonly users: User[] = [];
private nextId = 1;
async findById(id: number): Promise<User | null> {
return this.users.find((user) => user.id === id) ?? null;
}
async findByEmail(email: string): Promise<User | null> {
return this.users.find((user) => user.email === email) ?? null;
}
async save(data: CreateUserData): Promise<User> {
const user = new User();
user.id = this.nextId++;
user.email = data.email;
user.name = data.name;
this.users.push(user);
return user;
}
}У тестовому модулі можна підмінити провайдер:
{
provide: USER_REPOSITORY,
useClass: InMemoryUserRepository,
}UsersService при цьому не змінюється. Він працює з тим самим контрактом UserRepository.
запити до бази даних;
перетворення параметрів на формат ORM;
створення та збереження сутностей;
отримання даних;
особливості конкретного сховища.
бізнес-правила;
перевірку умов перед операцією;
вибір потрібних операцій репозиторію;
формування бізнес-помилок;
координацію кількох залежностей.
Наприклад, перевірка унікальності email може бути частиною бізнес-логіки сервісу, а пошук користувача за email — відповідальністю репозиторію.
Водночас обмеження унікальності на рівні бази даних також потрібне для захисту від конкурентних запитів. Репозиторій може перетворити помилку бази на доменну або прикладну помилку, якщо це потрібно архітектурі застосунку.
Патерн особливо корисний, коли:
бізнес-логіку потрібно тестувати без підключення до бази;
застосунок має складні запити;
планується заміна ORM або сховища;
одна модель використовується в різних сценаріях;
потрібно централізувати правила доступу до даних.
Для дуже простого CRUD невеликий сервіс із прямим використанням @InjectRepository може бути достатнім. Репозиторій не повинен створюватися лише заради додаткового шару абстракції без реальної користі.
Погано:
async findUsers(options: FindManyOptions<User>) {
return this.users.find(options);
}Такий метод переносить API TypeORM у бізнес-шар.
Краще визначити конкретну операцію:
findActiveUsers(): Promise<User[]>;а формат запиту реалізувати всередині репозиторію.
Погано:
constructor(
private readonly users: UserRepository,
) {}NestJS не може використати TypeScript-інтерфейс під час виконання програми.
Правильно:
constructor(
@Inject(USER_REPOSITORY)
private readonly users: UserRepository,
) {}Репозиторій не повинен вирішувати, чи дозволено реєструвати користувача, чи має операція повертати ConflictException, або яка послідовність кроків потрібна для бізнес-сценарію.
Його завдання — надати дані та виконати операції зберігання.
Методи на кшталт find, update і delete з великою кількістю ORM-параметрів часто перетворюють абстракцію на копію API TypeORM.
Контракт краще робити мінімальним і формувати його на основі реальних потреб сервісу.
Якщо клас зареєстрований окремо та ще раз створюється через useClass, можна випадково отримати різні екземпляри.
Для вже зареєстрованого класу зручно використовувати:
{
provide: USER_REPOSITORY,
useExisting: TypeOrmUserRepository,
}Repository Pattern відокремлює бізнес-логіку від інфраструктурного коду.
Основна схема:
визначити інтерфейс репозиторію;
створити токен для Dependency Injection;
реалізувати репозиторій через TypeORM;
передати сервісу інтерфейс через @Inject;
зареєструвати відповідність токена та реалізації в модулі.
У результаті сервіс залежить не від TypeORM, а від контракту. Це спрощує тестування, локалізує запити до бази та дозволяє змінювати спосіб зберігання без переписування бізнес-логіки.