Урок 05

Сервисы и DI

Что такое сервис

Сервис — класс с бизнес-логикой. Не знает про HTTP. Используется контроллерами и другими сервисами.

Создание

nest g service users

Что создаст:

import { Injectable } from '@nestjs/common';

@Injectable()
export class UsersService {}

@Injectable()

Декоратор — помечает класс как провайдер, который NestJS может внедрять.

Без него — DI не сработает.

Базовый сервис

import { Injectable } from '@nestjs/common';

export interface User {
    id: number;
    name: string;
    email: string;
}

@Injectable()
export class UsersService {
    private users: User[] = [];

    findAll(): User[] {
        return this.users;
    }

    findOne(id: number): User | undefined {
        return this.users.find(u => u.id === id);
    }

    create(data: Omit<User, 'id'>): User {
        const user = { id: this.users.length + 1, ...data };
        this.users.push(user);
        return user;
    }

    update(id: number, data: Partial<User>): User {
        const user = this.findOne(id);
        if (!user) throw new Error('Not found');
        Object.assign(user, data);
        return user;
    }

    remove(id: number): void {
        this.users = this.users.filter(u => u.id !== id);
    }
}

Использование в контроллере

@Controller('users')
export class UsersController {
    constructor(private readonly usersService: UsersService) {}

    @Get()
    findAll() {
        return this.usersService.findAll();
    }
}

NestJS автоматически создаст UsersService и передаст в конструктор.

Как работает DI

Dependency Injection — передача зависимостей через конструктор.

NestJS IoC-контейнер:

  1. Сканирует модули.
  2. Регистрирует провайдеры.
  3. Резолвит зависимости.
  4. Создаёт граф.
  5. Инстанцирует в правильном порядке.

Инъекция сервис в сервис

orders.service.ts:

@Injectable()
export class OrdersService {
    constructor(private readonly usersService: UsersService) {}

    findAll() {
        return [{
            id: 1,
            user: this.usersService.findOne(1),
        }];
    }
}

Сервис использует другой сервис. Нужно, чтобы UsersService был экспортирован из своего модуля.

Scope провайдеров

Три scope:

  • DEFAULT — singleton, один на всё приложение.
  • REQUEST — новый на каждый запрос.
  • TRANSIENT — новый на каждый инжект.
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {}

Правило: по умолчанию — DEFAULT. REQUEST — если нужен доступ к req.

Опциональная зависимость

constructor(
    @Optional() private readonly logger?: LoggerService,
) {}

@Optional() — не ошибка, если не найден.

Кастомный провайдер

useClass:

@Module({
    providers: [
        {
            provide: LoggerService,
            useClass: process.env.NODE_ENV === 'production'
                ? FileLogger
                : ConsoleLogger,
        },
    ],
})

useValue:

@Module({
    providers: [
        {
            provide: 'API_KEY',
            useValue: 'secret-key',
        },
    ],
})

Инъекция:

constructor(@Inject('API_KEY') private apiKey: string) {}

useFactory:

@Module({
    providers: [
        {
            provide: 'DATABASE',
            useFactory: async (config: ConfigService) => {
                return createConnection(config.get('DB_URL'));
            },
            inject: [ConfigService],
        },
    ],
})

Асинхронный провайдер

{
    provide: 'CONNECTION',
    useFactory: async () => {
        const conn = await connect();
        return conn;
    },
}

NestJS дождётся промиса.

Абстракция через интерфейс

Правильный подход — интерфейс + токен:

// payment.service.ts
export interface PaymentService {
    charge(amount: number): Promise<boolean>;
}

export const PAYMENT_SERVICE = 'PAYMENT_SERVICE';

Реализации:

@Injectable()
export class StripeService implements PaymentService {
    async charge(amount: number): Promise<boolean> {
        return true;
    }
}

Провайдер:

@Module({
    providers: [
        {
            provide: PAYMENT_SERVICE,
            useClass: StripeService,
        },
    ],
})

Инъекция:

constructor(
    @Inject(PAYMENT_SERVICE)
    private readonly payment: PaymentService,
) {}

Теперь легко подменить в тестах.

Repository pattern

Сервис + репозиторий:

@Injectable()
export class UsersService {
    constructor(private readonly usersRepository: UsersRepository) {}

    async findAll() {
        return this.usersRepository.findAll();
    }
}

@Injectable()
export class UsersRepository {
    constructor(
        @InjectRepository(User) private repo: Repository<User>,
    ) {}

    async findAll() {
        return this.repo.find();
    }
}

Разделение: сервис — логика, репозиторий — БД.

Общие сервисы

Global module:

@Global()
@Module({
    providers: [LoggerService],
    exports: [LoggerService],
})
export class LoggerModule {}

Подключить один раз в AppModule.

Тестирование сервиса

Unit-тест:

import { Test } from '@nestjs/testing';
import { UsersService } from './users.service';

describe('UsersService', () => {
    let service: UsersService;

    beforeEach(async () => {
        const module = await Test.createTestingModule({
            providers: [UsersService],
        }).compile();

        service = module.get<UsersService>(UsersService);
    });

    it('should create user', () => {
        const user = service.create({ name: 'Иван', email: 'ivan@example.com' });
        expect(user.id).toBe(1);
        expect(user.name).toBe('Иван');
    });
});

Мок зависимости

describe('UsersService', () => {
    let service: UsersService;
    let repo: UsersRepository;

    beforeEach(async () => {
        const module = await Test.createTestingModule({
            providers: [
                UsersService,
                {
                    provide: UsersRepository,
                    useValue: {
                        findAll: jest.fn().mockResolvedValue([]),
                        findOne: jest.fn(),
                    },
                },
            ],
        }).compile();

        service = module.get(UsersService);
        repo = module.get(UsersRepository);
    });

    it('should call repo', async () => {
        await service.findAll();
        expect(repo.findAll).toHaveBeenCalled();
    });
});

Практика: сервис с логгером

import { Injectable, Logger } from '@nestjs/common';

@Injectable()
export class UsersService {
    private readonly logger = new Logger(UsersService.name);
    private users: User[] = [];

    create(data: Omit<User, 'id'>): User {
        const user = { id: this.users.length + 1, ...data };
        this.users.push(user);
        this.logger.log(`Создан пользователь ${user.id}`);
        return user;
    }

    findOne(id: number): User | undefined {
        this.logger.debug(`Поиск пользователя ${id}`);
        return this.users.find(u => u.id === id);
    }
}

Logger — встроенный в NestJS.

Порядок инициализации

OnModuleInit:

@Injectable()
export class UsersService implements OnModuleInit {
    onModuleInit() {
        console.log('UsersService готов');
    }
}

Хуки жизненного цикла:

  • OnModuleInit — после инициализации.
  • OnModuleDestroy — при завершении.
  • OnApplicationBootstrap — после bootstrap.
  • OnApplicationShutdown — при shutdown.

Итог

  • @Injectable() — провайдер.
  • Constructor injection — основной способ.
  • @Inject('token') — для не-классов.
  • Scope: DEFAULT, REQUEST, TRANSIENT.
  • useClass, useValue, useFactory — кастомные.
  • Интерфейс + токен — для абстракций.
  • Repository — доступ к БД.
  • Test.createTestingModule — тесты.
  • Logger — встроенный.

Дальше — DTO и валидация.